diff --git a/.dockerignore b/.dockerignore index 7848ac67391..a04ecd67624 100644 --- a/.dockerignore +++ b/.dockerignore @@ -57,5 +57,10 @@ gui/.vite/ !native/remote-workspace-helper/Cargo.lock !native/remote-workspace-helper/src/ !native/remote-workspace-helper/src/** +!native/ocx-child/ +!native/ocx-child/Cargo.toml +!native/ocx-child/Cargo.lock +!native/ocx-child/src/ +!native/ocx-child/src/** **/*.log diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 07fd1d1cf6b..7e4c10e1607 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,7 +4,7 @@ contact_links: url: https://github.com/lidge-jun/opencodex/security/advisories/new about: Report undisclosed vulnerabilities privately to the maintainers. Do not open a public issue. - name: Security policy - url: https://github.com/lidge-jun/opencodex/blob/main/SECURITY.md + url: https://github.com/lidge-jun/opencodex/blob/dev/SECURITY.md about: Read the supported-version and reporting guidance before sharing security-sensitive details. - name: Contributing guide url: https://opencodex.me/contributing/ diff --git a/.github/scripts/bcdn-package.py b/.github/scripts/bcdn-package.py index 2ec33365523..f0f3f502cf2 100644 --- a/.github/scripts/bcdn-package.py +++ b/.github/scripts/bcdn-package.py @@ -36,7 +36,9 @@ required = ['package.json', 'gui/dist/index.html', 'bin/ocx.mjs', 'bin/package-main.mjs', 'src/generated/compatibility-version.json', 'README.md', 'LICENSE', 'AGENTS_INSTALL.md', - 'native/remote-workspace-helper/Cargo.toml', 'native/remote-workspace-helper/Cargo.lock'] + 'native/remote-workspace-helper/Cargo.toml', 'native/remote-workspace-helper/Cargo.lock', + 'native/ocx-child/Cargo.toml', 'native/ocx-child/Cargo.lock', + 'native/ocx-child/src/lib.rs', 'native/ocx-child/src/main.rs'] if any(f'package/{name}' not in files for name in required): raise ValueError('Missing required package files') if metadata['name'] != '@bitkyc08/opencodex' or not re.fullmatch(r'\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?', metadata['version']): diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs index a07e89be080..4fd284e4a46 100644 --- a/.github/scripts/enforce-pr-target.test.cjs +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -165,24 +165,23 @@ describe("enforce-pr-target workflow", () => { .split("- name: Checkout trusted PR-quality scripts")[1] .split(/\n {6}- name:/)[0]; assert.match(checkoutStep, /actions\/checkout@[0-9a-f]{40}/); - // The trusted ref comes from a fixed set of integration branches, never - // from the PR's own base commit: a stacked child's base is another open - // PR's head, and `base.sha` would let that unpromoted commit choose the - // code that runs with this job's write-capable token. + // The trusted ref is an integration branch, never the PR's own + // base commit: a stacked child's base is another open PR's head, and + // `base.sha` would let that unpromoted commit choose the code that runs + // with this job's write-capable token. // // `status` has no pull_request payload and sources from the default branch - // that supplied the privileged workflow. A `main`-targeting PR sources - // from `main` so the workflow definition and the scripts match. The - // donadiosolutions fork's `custom` base uses `custom`; other bases use `dev`. + // that supplied the privileged workflow. The donadiosolutions fork's + // `custom` base uses `custom`; other bases use `dev`. // - // Exact equality, not fragment matching: separate checks for `status`, - // `main`, and `dev` would all pass with the operator grouping wrong or a - // surviving `base.sha` fallback. + // Exact equality, not fragment matching: separate checks for `status` and + // `dev` would both pass with the operator grouping wrong or a surviving + // `base.sha` fallback. const ref = checkoutStep.match(/^\s*ref:\s*(.+)$/m)?.[1]; assert.ok(ref, "trusted checkout must declare ref"); assert.equal( ref.replace(/\s+/g, " ").trim(), - "${{ github.event_name == 'status' && github.event.repository.default_branch || (github.event.pull_request.base.ref == 'main' && 'main' || (github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev')) }}", + "${{ github.event_name == 'status' && github.event.repository.default_branch || (github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev') }}", ); assert.doesNotMatch(ref, /base\.sha|head\.(?:sha|ref)/); // Pinning the checkout ref only gates one step. A later `run:` or @@ -215,7 +214,7 @@ describe("enforce-pr-target workflow", () => { const checkouts = workflow.match(/uses:\s*actions\/checkout@[\s\S]*?(?=\n {6}- name:|$)/g) ?? []; for (const step of checkouts) { const stepRef = (step.match(/^\s*ref:\s*(.+)$/m)?.[1] ?? "").replace(/\s+/g, " ").trim(); - assert.equal(stepRef, "${{ github.event_name == 'status' && github.event.repository.default_branch || (github.event.pull_request.base.ref == 'main' && 'main' || (github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev')) }}", "every checkout must use the trusted ref"); + assert.equal(stepRef, "${{ github.event_name == 'status' && github.event.repository.default_branch || (github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev') }}", "every checkout must use the trusted ref"); assert.doesNotMatch(step, /repository:/, "a checkout must not retarget its repository"); } // Checkout is not the only way to obtain PR-controlled code. A `run:` step diff --git a/.github/scripts/pr-hygiene.test.cjs b/.github/scripts/pr-hygiene.test.cjs index 26b432942c7..1e8727e132c 100644 --- a/.github/scripts/pr-hygiene.test.cjs +++ b/.github/scripts/pr-hygiene.test.cjs @@ -314,7 +314,7 @@ describe("pr-hygiene workflow trust boundary", () => { assert.ok(ref, "trusted checkout must declare ref"); assert.equal( ref.replace(/\s+/g, " ").trim(), - "${{ github.event.pull_request.base.ref == 'main' && 'main' || (github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev') }}", + "${{ github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev' }}", ); // A stacked child PR's base is another open PR's head; neither a base nor // a head ref may select the code that runs with the write-capable token. @@ -355,7 +355,7 @@ describe("pr-hygiene workflow trust boundary", () => { const checkouts = workflow.match(/uses:\s*actions\/checkout@[\s\S]*?(?=\n {6}- name:|$)/g) ?? []; for (const step of checkouts) { const stepRef = (step.match(/^\s*ref:\s*(.+)$/m)?.[1] ?? "").replace(/\s+/g, " ").trim(); - assert.equal(stepRef, "${{ github.event.pull_request.base.ref == 'main' && 'main' || (github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev') }}", "every checkout must use the trusted ref"); + assert.equal(stepRef, "${{ github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev' }}", "every checkout must use the trusted ref"); assert.doesNotMatch(step, /repository:/, "a checkout must not retarget its repository"); } // Checkout is not the only way to obtain PR-controlled code. A `run:` step diff --git a/.github/scripts/pr-quality-messages.cjs b/.github/scripts/pr-quality-messages.cjs index 56233036ebc..c37625ea516 100644 --- a/.github/scripts/pr-quality-messages.cjs +++ b/.github/scripts/pr-quality-messages.cjs @@ -274,17 +274,7 @@ function buildFailureSections(failures, { pr, allowedBases, defaultBase }) { "", `This pull request currently targets ${inlineCode(pr.base.ref)}, but pull requests must target one of ${allowedBases.map(inlineCode).join(" or ")}.`, "", - `@${pr.user.login} Please retarget this PR to ${inlineCode(defaultBase)}. All contributions go to ${inlineCode(defaultBase)}; \`main\` receives only release promotions. See our [Contributing guide](https://lidge-jun.github.io/opencodex/contributing/) for details. Thanks! 🙏` - ); - } - - if (failures.some(failure => failure.code === "wrong_ancestry")) { - sections.push( - "⚠️ **Wrong branch ancestry**", - "", - `This pull request targets ${inlineCode(pr.base.ref)}, but its head appears to sit on the current ${inlineCode("main")} tip while being far behind ${inlineCode(pr.base.ref)}.`, - "", - `@${pr.user.login} Rebase onto the current ${inlineCode(pr.base.ref)} branch instead of opening from ${inlineCode("main")}. That keeps already-released commits out of the integration branch.` + `@${pr.user.login} Please retarget this PR to ${inlineCode(defaultBase)}. All contributions go to ${inlineCode(defaultBase)}. See our [Contributing guide](https://lidge-jun.github.io/opencodex/contributing/) for details. Thanks! 🙏` ); } @@ -322,9 +312,6 @@ function failureSummary(failures, { pr }) { if (failure.code === "wrong_base") { return `wrong base (${pr.base.ref})`; } - if (failure.code === "wrong_ancestry") { - return "wrong ancestry"; - } if (failure.code === "bad_description") { return `bad description (${failure.reason})`; } diff --git a/.github/scripts/pr-quality-messages.test.cjs b/.github/scripts/pr-quality-messages.test.cjs index f4665ae0d6a..ad209ccc325 100644 --- a/.github/scripts/pr-quality-messages.test.cjs +++ b/.github/scripts/pr-quality-messages.test.cjs @@ -143,19 +143,17 @@ describe("buildFailureSections", () => { assert.match(sections, /@contributor Please retarget this PR to `dev`/); }); - it("builds ancestry, description, and screenshot sections", () => { + it("builds description and screenshot sections", () => { const sections = buildFailureSections( [ - { code: "wrong_ancestry" }, { code: "bad_description", reason: "empty" }, { code: "missing_ui_screenshot" } ], { pr: PR, allowedBases: ALLOWED_BASES, defaultBase: DEFAULT_BASE }, ).join("\n"); - assert.match(sections, /Wrong branch ancestry/); assert.match(sections, /Pull request description/); assert.match(sections, /UI screenshot required/); - assert.match(sections, /Rebase onto the current `main`/); + assert.doesNotMatch(sections, /Wrong branch ancestry/); }); }); @@ -165,14 +163,13 @@ describe("failureSummary", () => { failureSummary( [ { code: "wrong_base" }, - { code: "wrong_ancestry" }, { code: "bad_description", reason: "empty" }, { code: "missing_ui_screenshot" }, { code: "mystery" } ], { pr: PR }, ), - "wrong base (main); wrong ancestry; bad description (empty); missing UI screenshot; mystery", + "wrong base (main); bad description (empty); missing UI screenshot; mystery", ); }); }); diff --git a/.github/scripts/pr-quality.cjs b/.github/scripts/pr-quality.cjs index ddee9226bb6..edc95afbd8f 100644 --- a/.github/scripts/pr-quality.cjs +++ b/.github/scripts/pr-quality.cjs @@ -12,9 +12,6 @@ const { READINESS_LATEST_DEV_BEHIND_MAX } = require( path.join(__dirname, "pr-quality-state.cjs") ); -const ANCESTRY_BEHIND_THRESHOLD = 20; -/** Cap on ahead_by vs main so stale `dev` forks (many commits ahead of main) are not flagged. */ -const ANCESTRY_AHEAD_MAIN_MAX = 5; const MIN_SECTION_LEN = 40; const MIN_RICH_SECTIONS = 2; const UNSTRUCTURED_MIN_LEN = 120; @@ -106,20 +103,6 @@ const MARKDOWN_REFERENCE_DEF_RE = /^\s*\[([^\]]+)\]:\s*\S+/gm; /** Embedded HTML image with a renderable `src` (``). */ const HTML_IMAGE_RE = /]*\bsrc\s*=\s*(?:"[^"]+"|'[^']+'|[^\s>"']+)[^>]*>/i; -function isWrongAncestry({ - behindMain, - behindBase, - aheadMain = 0, - threshold = ANCESTRY_BEHIND_THRESHOLD, - aheadMainMax = ANCESTRY_AHEAD_MAIN_MAX, -}) { - return ( - behindMain === 0 && - behindBase >= threshold && - aheadMain <= aheadMainMax - ); -} - function authorHasPushPermission(permission) { return permission === "admin" || permission === "maintain" || permission === "write"; } @@ -528,12 +511,6 @@ function collectPrQualityFailures({ allowedBases, title = "", body, - behindMain, - behindBase, - aheadMain = 0, - authorPermission, - permissionLookupFailed = false, - ancestryLookupFailed = false, /** True when baseRef is another open PR's head (stacked child). */ stackedBase = false, /** Issue comments; a maintainer comment waives the GUI-screenshot gate. */ @@ -550,21 +527,6 @@ function collectPrQualityFailures({ const wrongBase = !allowedBases.includes(baseRef) && !stackedBase; if (wrongBase) { failures.push({ code: "wrong_base" }); - } else { - // Permission lookup fails closed (still evaluate ancestry). Compare API - // failures skip ancestry — zeros would falsely pass the #644 heuristic. - // Stacked children skip ancestry against the integration base; their parent - // PR is the temporary target. - const skipAncestry = - stackedBase || - ancestryLookupFailed || - (!permissionLookupFailed && authorHasPushPermission(authorPermission)); - if ( - !skipAncestry && - isWrongAncestry({ behindMain, behindBase, aheadMain }) - ) { - failures.push({ code: "wrong_ancestry" }); - } } const desc = assessPrDescription(body); @@ -587,12 +549,9 @@ function collectPrQualityFailures({ } module.exports = { - ANCESTRY_BEHIND_THRESHOLD, - ANCESTRY_AHEAD_MAIN_MAX, REVIEW_READINESS_ITEMS, REVIEW_READINESS_START, REVIEW_READINESS_END, - isWrongAncestry, authorHasPushPermission, assessPrDescription, guiPathsChanged, diff --git a/.github/scripts/pr-quality.test.cjs b/.github/scripts/pr-quality.test.cjs index 996e5b08676..b4287bfeb0f 100644 --- a/.github/scripts/pr-quality.test.cjs +++ b/.github/scripts/pr-quality.test.cjs @@ -3,9 +3,7 @@ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); const { - ANCESTRY_BEHIND_THRESHOLD, REVIEW_READINESS_ITEMS, - isWrongAncestry, authorHasPushPermission, assessPrDescription, hasGuiCue, @@ -25,32 +23,6 @@ const { collectPrQualityFailures, } = require("./pr-quality.cjs"); -describe("isWrongAncestry", () => { - it("flags #644-shaped compares (0 behind main, far behind base, few ahead of main)", () => { - assert.equal( - isWrongAncestry({ behindMain: 0, behindBase: 44, aheadMain: 1 }), - true, - ); - }); - - it("uses threshold 20 by default", () => { - assert.equal(ANCESTRY_BEHIND_THRESHOLD, 20); - assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 20, aheadMain: 1 }), true); - assert.equal(isWrongAncestry({ behindMain: 0, behindBase: 19, aheadMain: 1 }), false); - }); - - it("passes when head is behind main (not sitting on main tip)", () => { - assert.equal(isWrongAncestry({ behindMain: 1, behindBase: 44, aheadMain: 1 }), false); - }); - - it("passes stale dev-based branches that are many commits ahead of main", () => { - assert.equal( - isWrongAncestry({ behindMain: 0, behindBase: 44, aheadMain: 50 }), - false, - ); - }); -}); - describe("authorHasPushPermission", () => { it("accepts write/maintain/admin only", () => { assert.equal(authorHasPushPermission("admin"), true); @@ -680,12 +652,8 @@ describe("collectPrQualityFailures", () => { baseRef: "main", allowedBases: allowed, body: "## Summary\n" + "x".repeat(50) + "\n\n## Test plan\n" + "y".repeat(50), - behindMain: 0, - behindBase: 0, - authorPermission: "read", }); assert.ok(failures.some((f) => f.code === "wrong_base")); - assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); }); it("reports wrong_base and bad_description together for main + empty body", () => { @@ -693,16 +661,16 @@ describe("collectPrQualityFailures", () => { baseRef: "main", allowedBases: allowed, body: "", - behindMain: 0, - behindBase: 0, - authorPermission: "read", }); assert.ok(failures.some((f) => f.code === "wrong_base")); assert.ok(failures.some((f) => f.code === "bad_description")); - assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); }); - it("reports wrong_ancestry for contributor on #644-shaped compare", () => { + it("no longer fails a contributor for sitting on the deleted main tip", () => { + // The old `main...head` heuristic flagged a head that was level with + // `main` but far behind `dev`. `main` no longer exists, so there is no tip + // to compare against and no ancestry failure to raise. Being far behind + // `dev` is handled by the review-readiness latest-dev claim instead. const failures = collectPrQualityFailures({ baseRef: "dev", allowedBases: allowed, @@ -714,91 +682,17 @@ describe("collectPrQualityFailures", () => { "- Launch the tray app after setting CODEX_HOME", "- Confirm the listener and launcher use the same workspace root", ].join("\n"), - behindMain: 0, - behindBase: 44, - aheadMain: 1, - authorPermission: "read", }); - assert.deepEqual( - failures.map((f) => f.code), - ["wrong_ancestry"], - ); + assert.deepEqual(failures, []); }); - it("skips ancestry for push permission but still flags bad description", () => { + it("still flags a bad description for a contributor head", () => { const failures = collectPrQualityFailures({ baseRef: "dev", allowedBases: allowed, body: "", - behindMain: 0, - behindBase: 44, - aheadMain: 1, - authorPermission: "write", - }); - assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); - assert.ok(failures.some((f) => f.code === "bad_description")); - }); - - it("applies ancestry when permission lookup failed (fail closed)", () => { - const failures = collectPrQualityFailures({ - baseRef: "dev", - allowedBases: allowed, - body: [ - "## Summary", - "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", - "", - "## Test plan", - "- Launch the tray app after setting CODEX_HOME", - "- Confirm the listener and launcher use the same workspace root", - ].join("\n"), - behindMain: 0, - behindBase: 44, - aheadMain: 1, - authorPermission: null, - permissionLookupFailed: true, - }); - assert.ok(failures.some((f) => f.code === "wrong_ancestry")); - }); - - it("does not flag stale dev-based branches that are far ahead of main", () => { - const failures = collectPrQualityFailures({ - baseRef: "dev", - allowedBases: allowed, - body: [ - "## Summary", - "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", - "", - "## Test plan", - "- Launch the tray app after setting CODEX_HOME", - "- Confirm the listener and launcher use the same workspace root", - ].join("\n"), - behindMain: 0, - behindBase: 44, - aheadMain: 50, - authorPermission: "read", - }); - assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); - }); - - it("skips ancestry when compare lookup failed (cannot evaluate)", () => { - const failures = collectPrQualityFailures({ - baseRef: "dev", - allowedBases: allowed, - body: [ - "## Summary", - "This change updates the Windows tray launcher so it resolves CODEX_HOME through the shared helper instead of a hardcoded path.", - "", - "## Test plan", - "- Launch the tray app after setting CODEX_HOME", - "- Confirm the listener and launcher use the same workspace root", - ].join("\n"), - behindMain: 0, - behindBase: 0, - aheadMain: 0, - authorPermission: "read", - ancestryLookupFailed: true, }); - assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); + assert.deepEqual(failures.map((f) => f.code), ["bad_description"]); }); it("skips wrong_base when stackedBase is set", () => { @@ -813,14 +707,9 @@ describe("collectPrQualityFailures", () => { "- Launch the tray app after setting CODEX_HOME", "- Confirm the listener and launcher use the same workspace root", ].join("\n"), - behindMain: 0, - behindBase: 44, - aheadMain: 1, - authorPermission: "read", stackedBase: true, }); assert.ok(!failures.some((f) => f.code === "wrong_base")); - assert.ok(!failures.some((f) => f.code === "wrong_ancestry")); }); it("still flags wrong_base for non-allow-list bases without stackedBase", () => { @@ -828,9 +717,6 @@ describe("collectPrQualityFailures", () => { baseRef: "main", allowedBases: allowed, body: "fix stuff", - behindMain: 0, - behindBase: 0, - authorPermission: "read", stackedBase: false, }); assert.ok(failures.some((f) => f.code === "wrong_base")); @@ -842,9 +728,6 @@ describe("collectPrQualityFailures", () => { allowedBases: allowed, title: "Fix dashboard spacing", body: richBody, - behindMain: 0, - behindBase: 0, - authorPermission: "read", changedFilePaths: ["gui/src/App.tsx"], }); assert.ok(failures.some((f) => f.code === "missing_ui_screenshot")); @@ -856,9 +739,6 @@ describe("collectPrQualityFailures", () => { allowedBases: allowed, title: "GUI: fix provider list spacing", body: richBody, - behindMain: 0, - behindBase: 0, - authorPermission: "read", changedFilePaths: ["scripts/foo.ts"], }); assert.ok(!failures.some((f) => f.code === "missing_ui_screenshot")); @@ -871,9 +751,6 @@ describe("collectPrQualityFailures", () => { allowedBases: allowed, title: "Large refactor", body: richBody, - behindMain: 0, - behindBase: 0, - authorPermission: "read", changedFilePaths: truncatedPaths, filesTruncated: true, }); @@ -888,9 +765,6 @@ describe("collectPrQualityFailures", () => { allowedBases: allowed, title: "Large refactor with gui tweak", body: richBody, - behindMain: 0, - behindBase: 0, - authorPermission: "read", changedFilePaths: truncatedPaths, filesTruncated: true, }); @@ -909,9 +783,6 @@ describe("collectPrQualityFailures", () => { "## Test plan", "- Ran bun test tests/ci-workflows/ci-workflows.test.ts", ].join("\n"), - behindMain: 0, - behindBase: 0, - authorPermission: "read", changedFilePaths: ["scripts/foo.ts"], }); assert.ok(!failures.some((f) => f.code === "missing_ui_screenshot")); @@ -929,9 +800,6 @@ describe("collectPrQualityFailures", () => { "## Test plan", "- Ran bun test tests/ci-workflows/ci-workflows.test.ts", ].join("\n"), - behindMain: 0, - behindBase: 0, - authorPermission: "read", changedFilePaths: ["gui/src/styles.css"], }); assert.ok(failures.some((f) => f.code === "missing_ui_screenshot")); @@ -949,9 +817,6 @@ describe("collectPrQualityFailures", () => { "## Test plan", "- Ran bun test tests/ci-workflows/ci-workflows.test.ts", ].join("\n"), - behindMain: 0, - behindBase: 0, - authorPermission: "read", changedFilePaths: ["gui/src/App.tsx"], guiOverrideComments: [ { author_association: "OWNER", body: "no gui changes here" }, @@ -972,9 +837,6 @@ describe("collectPrQualityFailures", () => { "## Test plan", "- Ran bun test tests/ci-workflows/ci-workflows.test.ts", ].join("\n"), - behindMain: 0, - behindBase: 0, - authorPermission: "read", changedFilePaths: ["gui/src/App.tsx"], guiOverrideComments: [ { author_association: "CONTRIBUTOR", body: "no gui changes here" }, @@ -997,9 +859,6 @@ describe("collectPrQualityFailures", () => { "## Test plan", "- Ran bun test tests/ci-workflows/ci-workflows.test.ts", ].join("\n"), - behindMain: 0, - behindBase: 0, - authorPermission: "read", changedFilePaths: ["gui/src/App.tsx"], }); assert.ok(!failures.some((f) => f.code === "missing_ui_screenshot")); @@ -1021,9 +880,6 @@ describe("collectPrQualityFailures", () => { "## Test plan", "- Ran bun test tests/ci-workflows/ci-workflows.test.ts", ].join("\n"), - behindMain: 0, - behindBase: 0, - authorPermission: "read", changedFilePaths: ["gui/src/App.tsx"], }); assert.ok(!failures.some((f) => f.code === "missing_ui_screenshot")); @@ -1045,9 +901,6 @@ describe("collectPrQualityFailures", () => { "## Test plan", "- Ran bun test tests/ci-workflows/ci-workflows.test.ts", ].join("\n"), - behindMain: 0, - behindBase: 0, - authorPermission: "read", changedFilePaths: ["gui/src/App.tsx"], }); assert.ok(failures.some((f) => f.code === "missing_ui_screenshot")); @@ -1071,9 +924,6 @@ describe("collectPrQualityFailures", () => { "- [ ] Docs or release notes were updated when needed.", "- [ ] Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.", ].join("\n"), - behindMain: 0, - behindBase: 0, - authorPermission: "read", }); assert.ok(!failures.some((f) => f.code === "missing_ui_screenshot")); }); diff --git a/.github/scripts/release-dispatch-guard.cjs b/.github/scripts/release-dispatch-guard.cjs index 507cd3e787f..53504195230 100644 --- a/.github/scripts/release-dispatch-guard.cjs +++ b/.github/scripts/release-dispatch-guard.cjs @@ -1,44 +1,22 @@ "use strict"; -const ALLOWED_RELEASE_REFS = new Set([ - "refs/heads/main", - "refs/heads/preview", -]); +const ALLOWED_RELEASE_REFS = new Set(["refs/heads/dev"]); +const FULL_SHA = /^[0-9a-f]{40}$/; -function validateReleaseDispatch({ - eventName, - ref, - expectedSha, - actualSha, -}) { +function validateReleaseDispatch({ eventName, ref, sourceSha, workflowSha, expectedSha, actualSha }) { if (eventName !== "workflow_dispatch") { return `Release must be triggered by workflow_dispatch; got ${eventName || "(empty)"}.`; } - if (!ALLOWED_RELEASE_REFS.has(ref)) { - return `Release must run from main or preview; got ${ref || "(empty)"}.`; - } - - if (!expectedSha) { - return "expected-sha is required; refusing to publish without an audited commit."; - } - - if (!/^[0-9a-f]{40}$/.test(expectedSha)) { - return `expected-sha must be a full 40-character commit SHA; got ${expectedSha}.`; - } - - if (actualSha !== expectedSha) { - return ( - `branch moved after the release audit ` + - `(expected ${expectedSha}, got ${actualSha || "(empty)"}) — ` + - "refusing to publish an unaudited commit." - ); + return `Release must run from dev; got ${ref || "(empty)"}.`; } - + // Accept the old parameter names while callers migrate; the source is independently + // validated by release-evidence, not compared with the workflow revision. + const source = sourceSha ?? expectedSha ?? ""; + const workflow = workflowSha ?? actualSha ?? ""; + if (source && !FULL_SHA.test(source)) return "source-sha must be a full 40-character commit SHA."; + if (!FULL_SHA.test(workflow)) return "workflow SHA must be a full 40-character commit SHA."; return null; } -module.exports = { - ALLOWED_RELEASE_REFS, - validateReleaseDispatch, -}; +module.exports = { ALLOWED_RELEASE_REFS, validateReleaseDispatch }; diff --git a/.github/scripts/release-dispatch-guard.test.cjs b/.github/scripts/release-dispatch-guard.test.cjs index 7833ca09763..d1b1382b2f3 100644 --- a/.github/scripts/release-dispatch-guard.test.cjs +++ b/.github/scripts/release-dispatch-guard.test.cjs @@ -4,63 +4,50 @@ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); const { validateReleaseDispatch } = require("./release-dispatch-guard.cjs"); -const SHA = "0123456789abcdef0123456789abcdef01234567"; -const OTHER_SHA = "89abcdef0123456789abcdef0123456789abcdef"; +const SOURCE_SHA = "0123456789abcdef0123456789abcdef01234567"; +const WORKFLOW_SHA = "89abcdef0123456789abcdef0123456789abcdef"; function validate(overrides = {}) { return validateReleaseDispatch({ eventName: "workflow_dispatch", - ref: "refs/heads/main", - expectedSha: SHA, - actualSha: SHA, + ref: "refs/heads/dev", + sourceSha: SOURCE_SHA, + workflowSha: WORKFLOW_SHA, ...overrides, }); } describe("release dispatch guard", () => { - it("accepts an exact audited SHA on main", () => { + it("accepts a source distinct from the trusted workflow revision", () => { + assert.notEqual(SOURCE_SHA, WORKFLOW_SHA); assert.equal(validate(), null); }); - it("accepts an exact audited SHA on preview", () => { - assert.equal( - validate({ ref: "refs/heads/preview" }), - null, - ); + it("allows an omitted source for subsequent evidence resolution", () => { + assert.equal(validate({ sourceSha: "" }), null); }); - it("rejects non-workflow_dispatch events", () => { - assert.match( - validate({ eventName: "push" }), - /must be triggered by workflow_dispatch/, - ); + it("rejects non-dispatch events", () => { + for (const eventName of ["push", "pull_request", "", undefined]) { + assert.match(validate({ eventName }), /must be triggered by workflow_dispatch/); + } }); - it("rejects release dispatches from unapproved refs", () => { - assert.match( - validate({ ref: "refs/heads/dev" }), - /must run from main or preview/, - ); + it("rejects every non-dev ref", () => { + for (const ref of ["refs/heads/main", "refs/heads/preview", "refs/heads/feature", "refs/tags/dev", "dev", "", undefined]) { + assert.match(validate({ ref }), /must run from dev/); + } }); - it("requires expected-sha", () => { - assert.match( - validate({ expectedSha: "" }), - /expected-sha is required/, - ); + it("rejects malformed nonempty source SHAs", () => { + for (const sourceSha of ["0123456", "A".repeat(40), "g".repeat(40), "a".repeat(41), ` ${SOURCE_SHA}`]) { + assert.match(validate({ sourceSha }), /source-sha must be a full 40-character commit SHA/); + } }); - it("requires a full 40-character commit SHA", () => { - assert.match( - validate({ expectedSha: "0123456" }), - /full 40-character commit SHA/, - ); - }); - - it("rejects when the selected ref moved after audit", () => { - assert.match( - validate({ actualSha: OTHER_SHA }), - /branch moved after the release audit/, - ); + it("requires a valid full workflow SHA even when source is omitted", () => { + for (const workflowSha of ["", undefined, "0123456", "A".repeat(40), "g".repeat(40), "a".repeat(41)]) { + assert.match(validate({ sourceSha: "", workflowSha }), /workflow SHA must be a full 40-character commit SHA/); + } }); }); diff --git a/.github/scripts/release-evidence-policy.json b/.github/scripts/release-evidence-policy.json new file mode 100644 index 00000000000..835a8e15a4c --- /dev/null +++ b/.github/scripts/release-evidence-policy.json @@ -0,0 +1,306 @@ +[ + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "b9c739e5d80e915d20bf776b1c698d92a0f01464", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d", + ".github/scripts/release-evidence.cjs": "ce462107c70c4d1d33b1a88169bcaf07a2bc380a", + ".github/scripts/release-evidence.test.cjs": "a05e126ee2c17f00f1f4fb06c84eae2655aa1576", + "scripts/ci/select-test-lane.sh": "b25836cb55f962d1ffd9e68ed00f45f8b45becc8", + "scripts/ci/windows-sensitive-diff.sh": "03495f256c671aff57e750febffe19ca7face6a1", + "scripts/ci/run-bun-test-batches.sh": "f8aa18e80aff1c95c584cd2c879dfbb6806c11fb", + "scripts/ci/bun-crash-signatures.sh": "d34662ddd07864d97bed909135095db33fe42c9e", + "scripts/ci/bun-install-frozen.sh": "e6d7bc3aec044e12297c7c4c04015a33df0eb22b", + "scripts/ci/docker-smoke.ts": "a8424f93986a31625a0a236cdab5667db67b3405", + "scripts/ci/test-durations.tsv": "0c7b8eeac35a5f09414d1b77185733b5936c594f", + "scripts/ci/test-durations.windows.tsv": "5ee31b6bd6cada6ab7d806ea03dd0d8e46fe1d29", + "scripts/test.ts": "363bf072c4b689478fdbb76a61bf4eadbc054548", + "scripts/lib/test-runner-bun.ts": "8763806104a851dbdefeea8cffe8d7dd8d1c8da1", + "scripts/test-temp-lock.ts": "ee83fb254b728de119028f52b9894bf52d7aea15", + "scripts/ci/run-changed-tests.sh": "9a54d3a5db9849bcbe0dd766954a840185419fa5", + "scripts/keyring-smoke.ts": "cd3be07ad57796172696dfa053ea1fb129f29365", + "scripts/privacy-scan.ts": "86314e891b45e49739b94c76cddd9e4cc2d4340f", + "scripts/structure-ssot.ts": "40ba68401df9b77156afed66d6a11c61df8e9a41", + "scripts/generate-ocx-skill-surface.ts": "750ea21e5668aa512a83aa5a7f8cd7bc5cbd79db", + "desktop/scripts/prepare-sidecar.ts": "20f7eec61f73635ea681e25f13dd850b62bb41e2", + "desktop/scripts/build-widget.sh": "38e0eb9d713804b603aa75c32383aa18ef01d06c", + "desktop/scripts/verify-macos-runtime.sh": "43f1a31375c6f6fe7dcafb9d96c231b98d1f6ba4", + "desktop/scripts/verify-linux-sidecar.sh": "c502bb5c8ba8346c722a8f5274f2274bb325a934", + "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", + "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" + } + }, + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "5cef76e77d391873e35012b3e9318c3d54e86da5", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d", + ".github/scripts/release-evidence.cjs": "c4cd8c762abbfc1c0674937535c5a36b9cf705fd", + ".github/scripts/release-evidence.test.cjs": "4ecdd6ea059794e424372f6afc0eb850f32061f4", + "scripts/ci/select-test-lane.sh": "b25836cb55f962d1ffd9e68ed00f45f8b45becc8", + "scripts/ci/windows-sensitive-diff.sh": "03495f256c671aff57e750febffe19ca7face6a1", + "scripts/ci/run-bun-test-batches.sh": "f8aa18e80aff1c95c584cd2c879dfbb6806c11fb", + "scripts/ci/bun-crash-signatures.sh": "d34662ddd07864d97bed909135095db33fe42c9e", + "scripts/ci/bun-install-frozen.sh": "e6d7bc3aec044e12297c7c4c04015a33df0eb22b", + "scripts/ci/docker-smoke.ts": "a8424f93986a31625a0a236cdab5667db67b3405", + "scripts/ci/test-durations.tsv": "0c7b8eeac35a5f09414d1b77185733b5936c594f", + "scripts/ci/test-durations.windows.tsv": "5ee31b6bd6cada6ab7d806ea03dd0d8e46fe1d29", + "scripts/test.ts": "363bf072c4b689478fdbb76a61bf4eadbc054548", + "scripts/lib/test-runner-bun.ts": "8763806104a851dbdefeea8cffe8d7dd8d1c8da1", + "scripts/test-temp-lock.ts": "ee83fb254b728de119028f52b9894bf52d7aea15", + "scripts/ci/run-changed-tests.sh": "9a54d3a5db9849bcbe0dd766954a840185419fa5", + "scripts/keyring-smoke.ts": "cd3be07ad57796172696dfa053ea1fb129f29365", + "scripts/privacy-scan.ts": "86314e891b45e49739b94c76cddd9e4cc2d4340f", + "scripts/structure-ssot.ts": "40ba68401df9b77156afed66d6a11c61df8e9a41", + "scripts/generate-ocx-skill-surface.ts": "750ea21e5668aa512a83aa5a7f8cd7bc5cbd79db", + "desktop/scripts/prepare-sidecar.ts": "20f7eec61f73635ea681e25f13dd850b62bb41e2", + "desktop/scripts/build-widget.sh": "38e0eb9d713804b603aa75c32383aa18ef01d06c", + "desktop/scripts/verify-macos-runtime.sh": "43f1a31375c6f6fe7dcafb9d96c231b98d1f6ba4", + "desktop/scripts/verify-linux-sidecar.sh": "c502bb5c8ba8346c722a8f5274f2274bb325a934", + "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", + "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" + } + }, + { + "kind": "service", + "version": 1, + "files": { + ".github/workflows/service-lifecycle.yml": "496319bee65c1030cf7905406b85fb9e716a22d5", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d" + } + }, + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "a53d40c6d971bba9a33c546f242c78b0f7695680", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d", + ".github/scripts/release-evidence.cjs": "c4cd8c762abbfc1c0674937535c5a36b9cf705fd", + ".github/scripts/release-evidence.test.cjs": "4ecdd6ea059794e424372f6afc0eb850f32061f4", + "scripts/ci/select-test-lane.sh": "b25836cb55f962d1ffd9e68ed00f45f8b45becc8", + "scripts/ci/windows-sensitive-diff.sh": "03495f256c671aff57e750febffe19ca7face6a1", + "scripts/ci/run-bun-test-batches.sh": "f8aa18e80aff1c95c584cd2c879dfbb6806c11fb", + "scripts/ci/bun-crash-signatures.sh": "d34662ddd07864d97bed909135095db33fe42c9e", + "scripts/ci/bun-install-frozen.sh": "e6d7bc3aec044e12297c7c4c04015a33df0eb22b", + "scripts/ci/docker-smoke.ts": "a8424f93986a31625a0a236cdab5667db67b3405", + "scripts/ci/test-durations.tsv": "0c7b8eeac35a5f09414d1b77185733b5936c594f", + "scripts/ci/test-durations.windows.tsv": "5ee31b6bd6cada6ab7d806ea03dd0d8e46fe1d29", + "scripts/test.ts": "363bf072c4b689478fdbb76a61bf4eadbc054548", + "scripts/lib/test-runner-bun.ts": "8763806104a851dbdefeea8cffe8d7dd8d1c8da1", + "scripts/test-temp-lock.ts": "ee83fb254b728de119028f52b9894bf52d7aea15", + "scripts/ci/run-changed-tests.sh": "9a54d3a5db9849bcbe0dd766954a840185419fa5", + "scripts/keyring-smoke.ts": "cd3be07ad57796172696dfa053ea1fb129f29365", + "scripts/privacy-scan.ts": "86314e891b45e49739b94c76cddd9e4cc2d4340f", + "scripts/structure-ssot.ts": "40ba68401df9b77156afed66d6a11c61df8e9a41", + "scripts/generate-ocx-skill-surface.ts": "750ea21e5668aa512a83aa5a7f8cd7bc5cbd79db", + "desktop/scripts/prepare-sidecar.ts": "20f7eec61f73635ea681e25f13dd850b62bb41e2", + "desktop/scripts/build-widget.sh": "38e0eb9d713804b603aa75c32383aa18ef01d06c", + "desktop/scripts/verify-macos-runtime.sh": "43f1a31375c6f6fe7dcafb9d96c231b98d1f6ba4", + "desktop/scripts/verify-linux-sidecar.sh": "c502bb5c8ba8346c722a8f5274f2274bb325a934", + "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", + "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" + } + }, + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "562e63d6bd140944d8c6ba808010913463c75826", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d", + ".github/scripts/release-evidence.cjs": "c4cd8c762abbfc1c0674937535c5a36b9cf705fd", + ".github/scripts/release-evidence.test.cjs": "de30ad37105cf0e7dd8c538efb5ee1cfafe02990", + "scripts/ci/select-test-lane.sh": "b25836cb55f962d1ffd9e68ed00f45f8b45becc8", + "scripts/ci/windows-sensitive-diff.sh": "03495f256c671aff57e750febffe19ca7face6a1", + "scripts/ci/run-bun-test-batches.sh": "f8aa18e80aff1c95c584cd2c879dfbb6806c11fb", + "scripts/ci/bun-crash-signatures.sh": "d34662ddd07864d97bed909135095db33fe42c9e", + "scripts/ci/bun-install-frozen.sh": "e6d7bc3aec044e12297c7c4c04015a33df0eb22b", + "scripts/ci/docker-smoke.ts": "a8424f93986a31625a0a236cdab5667db67b3405", + "scripts/ci/test-durations.tsv": "0c7b8eeac35a5f09414d1b77185733b5936c594f", + "scripts/ci/test-durations.windows.tsv": "5ee31b6bd6cada6ab7d806ea03dd0d8e46fe1d29", + "scripts/test.ts": "363bf072c4b689478fdbb76a61bf4eadbc054548", + "scripts/lib/test-runner-bun.ts": "8763806104a851dbdefeea8cffe8d7dd8d1c8da1", + "scripts/test-temp-lock.ts": "ee83fb254b728de119028f52b9894bf52d7aea15", + "scripts/ci/run-changed-tests.sh": "9a54d3a5db9849bcbe0dd766954a840185419fa5", + "scripts/keyring-smoke.ts": "cd3be07ad57796172696dfa053ea1fb129f29365", + "scripts/privacy-scan.ts": "86314e891b45e49739b94c76cddd9e4cc2d4340f", + "scripts/structure-ssot.ts": "40ba68401df9b77156afed66d6a11c61df8e9a41", + "scripts/generate-ocx-skill-surface.ts": "750ea21e5668aa512a83aa5a7f8cd7bc5cbd79db", + "desktop/scripts/prepare-sidecar.ts": "20f7eec61f73635ea681e25f13dd850b62bb41e2", + "desktop/scripts/build-widget.sh": "38e0eb9d713804b603aa75c32383aa18ef01d06c", + "desktop/scripts/verify-macos-runtime.sh": "43f1a31375c6f6fe7dcafb9d96c231b98d1f6ba4", + "desktop/scripts/verify-linux-sidecar.sh": "c502bb5c8ba8346c722a8f5274f2274bb325a934", + "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", + "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" + } + }, + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "4d1d82f1d26d95d2448b1269dde1b7b14b73455e", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d", + ".github/scripts/release-evidence.cjs": "c4cd8c762abbfc1c0674937535c5a36b9cf705fd", + ".github/scripts/release-evidence.test.cjs": "de30ad37105cf0e7dd8c538efb5ee1cfafe02990", + "scripts/ci/select-test-lane.sh": "b25836cb55f962d1ffd9e68ed00f45f8b45becc8", + "scripts/ci/windows-sensitive-diff.sh": "03495f256c671aff57e750febffe19ca7face6a1", + "scripts/ci/run-bun-test-batches.sh": "f8aa18e80aff1c95c584cd2c879dfbb6806c11fb", + "scripts/ci/bun-crash-signatures.sh": "d34662ddd07864d97bed909135095db33fe42c9e", + "scripts/ci/bun-install-frozen.sh": "e6d7bc3aec044e12297c7c4c04015a33df0eb22b", + "scripts/ci/docker-smoke.ts": "a8424f93986a31625a0a236cdab5667db67b3405", + "scripts/ci/test-durations.tsv": "0c7b8eeac35a5f09414d1b77185733b5936c594f", + "scripts/ci/test-durations.windows.tsv": "5ee31b6bd6cada6ab7d806ea03dd0d8e46fe1d29", + "scripts/test.ts": "363bf072c4b689478fdbb76a61bf4eadbc054548", + "scripts/lib/test-runner-bun.ts": "8763806104a851dbdefeea8cffe8d7dd8d1c8da1", + "scripts/test-temp-lock.ts": "ee83fb254b728de119028f52b9894bf52d7aea15", + "scripts/ci/run-changed-tests.sh": "9a54d3a5db9849bcbe0dd766954a840185419fa5", + "scripts/keyring-smoke.ts": "cd3be07ad57796172696dfa053ea1fb129f29365", + "scripts/privacy-scan.ts": "86314e891b45e49739b94c76cddd9e4cc2d4340f", + "scripts/structure-ssot.ts": "40ba68401df9b77156afed66d6a11c61df8e9a41", + "scripts/generate-ocx-skill-surface.ts": "750ea21e5668aa512a83aa5a7f8cd7bc5cbd79db", + "desktop/scripts/prepare-sidecar.ts": "20f7eec61f73635ea681e25f13dd850b62bb41e2", + "desktop/scripts/build-widget.sh": "38e0eb9d713804b603aa75c32383aa18ef01d06c", + "desktop/scripts/verify-macos-runtime.sh": "43f1a31375c6f6fe7dcafb9d96c231b98d1f6ba4", + "desktop/scripts/verify-linux-sidecar.sh": "c502bb5c8ba8346c722a8f5274f2274bb325a934", + "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", + "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" + } + }, + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "dc5496ad7e6a146a94fbe8dfeaa3b73461feafbc", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d", + ".github/scripts/release-evidence.cjs": "c4cd8c762abbfc1c0674937535c5a36b9cf705fd", + ".github/scripts/release-evidence.test.cjs": "de30ad37105cf0e7dd8c538efb5ee1cfafe02990", + "scripts/ci/select-test-lane.sh": "b25836cb55f962d1ffd9e68ed00f45f8b45becc8", + "scripts/ci/windows-sensitive-diff.sh": "03495f256c671aff57e750febffe19ca7face6a1", + "scripts/ci/run-bun-test-batches.sh": "f8aa18e80aff1c95c584cd2c879dfbb6806c11fb", + "scripts/ci/bun-crash-signatures.sh": "d34662ddd07864d97bed909135095db33fe42c9e", + "scripts/ci/bun-install-frozen.sh": "e6d7bc3aec044e12297c7c4c04015a33df0eb22b", + "scripts/ci/docker-smoke.ts": "a8424f93986a31625a0a236cdab5667db67b3405", + "scripts/ci/test-durations.tsv": "0c7b8eeac35a5f09414d1b77185733b5936c594f", + "scripts/ci/test-durations.windows.tsv": "5ee31b6bd6cada6ab7d806ea03dd0d8e46fe1d29", + "scripts/test.ts": "363bf072c4b689478fdbb76a61bf4eadbc054548", + "scripts/lib/test-runner-bun.ts": "8763806104a851dbdefeea8cffe8d7dd8d1c8da1", + "scripts/test-temp-lock.ts": "ee83fb254b728de119028f52b9894bf52d7aea15", + "scripts/ci/run-changed-tests.sh": "9a54d3a5db9849bcbe0dd766954a840185419fa5", + "scripts/keyring-smoke.ts": "cd3be07ad57796172696dfa053ea1fb129f29365", + "scripts/privacy-scan.ts": "86314e891b45e49739b94c76cddd9e4cc2d4340f", + "scripts/structure-ssot.ts": "40ba68401df9b77156afed66d6a11c61df8e9a41", + "scripts/generate-ocx-skill-surface.ts": "750ea21e5668aa512a83aa5a7f8cd7bc5cbd79db", + "desktop/scripts/prepare-sidecar.ts": "20f7eec61f73635ea681e25f13dd850b62bb41e2", + "desktop/scripts/build-widget.sh": "38e0eb9d713804b603aa75c32383aa18ef01d06c", + "desktop/scripts/verify-macos-runtime.sh": "43f1a31375c6f6fe7dcafb9d96c231b98d1f6ba4", + "desktop/scripts/verify-linux-sidecar.sh": "c502bb5c8ba8346c722a8f5274f2274bb325a934", + "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", + "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" + } + }, + { + "kind": "service", + "version": 1, + "files": { + ".github/workflows/service-lifecycle.yml": "87249bf4d19ce47a6eafa59703fd8e52453cc924", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d" + } + }, + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "dc5496ad7e6a146a94fbe8dfeaa3b73461feafbc", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d", + ".github/scripts/release-evidence.cjs": "c4cd8c762abbfc1c0674937535c5a36b9cf705fd", + ".github/scripts/release-evidence.test.cjs": "de30ad37105cf0e7dd8c538efb5ee1cfafe02990", + "scripts/ci/select-test-lane.sh": "b25836cb55f962d1ffd9e68ed00f45f8b45becc8", + "scripts/ci/windows-sensitive-diff.sh": "03495f256c671aff57e750febffe19ca7face6a1", + "scripts/ci/run-bun-test-batches.sh": "f8aa18e80aff1c95c584cd2c879dfbb6806c11fb", + "scripts/ci/bun-crash-signatures.sh": "d34662ddd07864d97bed909135095db33fe42c9e", + "scripts/ci/bun-install-frozen.sh": "e6d7bc3aec044e12297c7c4c04015a33df0eb22b", + "scripts/ci/docker-smoke.ts": "a8424f93986a31625a0a236cdab5667db67b3405", + "scripts/ci/test-durations.tsv": "0c7b8eeac35a5f09414d1b77185733b5936c594f", + "scripts/ci/test-durations.windows.tsv": "5ee31b6bd6cada6ab7d806ea03dd0d8e46fe1d29", + "scripts/test.ts": "363bf072c4b689478fdbb76a61bf4eadbc054548", + "scripts/lib/test-runner-bun.ts": "8763806104a851dbdefeea8cffe8d7dd8d1c8da1", + "scripts/test-temp-lock.ts": "ee83fb254b728de119028f52b9894bf52d7aea15", + "scripts/ci/run-changed-tests.sh": "9a54d3a5db9849bcbe0dd766954a840185419fa5", + "scripts/keyring-smoke.ts": "cd3be07ad57796172696dfa053ea1fb129f29365", + "scripts/privacy-scan.ts": "86314e891b45e49739b94c76cddd9e4cc2d4340f", + "scripts/structure-ssot.ts": "40ba68401df9b77156afed66d6a11c61df8e9a41", + "scripts/generate-ocx-skill-surface.ts": "155e866656ba2d71e8fd48adda857451caf16322", + "desktop/scripts/prepare-sidecar.ts": "20f7eec61f73635ea681e25f13dd850b62bb41e2", + "desktop/scripts/build-widget.sh": "38e0eb9d713804b603aa75c32383aa18ef01d06c", + "desktop/scripts/verify-macos-runtime.sh": "43f1a31375c6f6fe7dcafb9d96c231b98d1f6ba4", + "desktop/scripts/verify-linux-sidecar.sh": "c502bb5c8ba8346c722a8f5274f2274bb325a934", + "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", + "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" + } + }, + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "dc5496ad7e6a146a94fbe8dfeaa3b73461feafbc", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d", + ".github/scripts/release-evidence.cjs": "c4cd8c762abbfc1c0674937535c5a36b9cf705fd", + ".github/scripts/release-evidence.test.cjs": "de30ad37105cf0e7dd8c538efb5ee1cfafe02990", + "scripts/ci/select-test-lane.sh": "b25836cb55f962d1ffd9e68ed00f45f8b45becc8", + "scripts/ci/windows-sensitive-diff.sh": "03495f256c671aff57e750febffe19ca7face6a1", + "scripts/ci/run-bun-test-batches.sh": "f8aa18e80aff1c95c584cd2c879dfbb6806c11fb", + "scripts/ci/bun-crash-signatures.sh": "d34662ddd07864d97bed909135095db33fe42c9e", + "scripts/ci/bun-install-frozen.sh": "e6d7bc3aec044e12297c7c4c04015a33df0eb22b", + "scripts/ci/docker-smoke.ts": "a8424f93986a31625a0a236cdab5667db67b3405", + "scripts/ci/test-durations.tsv": "0c7b8eeac35a5f09414d1b77185733b5936c594f", + "scripts/ci/test-durations.windows.tsv": "18c65823d855bb592cdd59bd102399bd9ead3967", + "scripts/test.ts": "363bf072c4b689478fdbb76a61bf4eadbc054548", + "scripts/lib/test-runner-bun.ts": "8763806104a851dbdefeea8cffe8d7dd8d1c8da1", + "scripts/test-temp-lock.ts": "ee83fb254b728de119028f52b9894bf52d7aea15", + "scripts/ci/run-changed-tests.sh": "9a54d3a5db9849bcbe0dd766954a840185419fa5", + "scripts/keyring-smoke.ts": "cd3be07ad57796172696dfa053ea1fb129f29365", + "scripts/privacy-scan.ts": "86314e891b45e49739b94c76cddd9e4cc2d4340f", + "scripts/structure-ssot.ts": "40ba68401df9b77156afed66d6a11c61df8e9a41", + "scripts/generate-ocx-skill-surface.ts": "155e866656ba2d71e8fd48adda857451caf16322", + "desktop/scripts/prepare-sidecar.ts": "20f7eec61f73635ea681e25f13dd850b62bb41e2", + "desktop/scripts/build-widget.sh": "38e0eb9d713804b603aa75c32383aa18ef01d06c", + "desktop/scripts/verify-macos-runtime.sh": "43f1a31375c6f6fe7dcafb9d96c231b98d1f6ba4", + "desktop/scripts/verify-linux-sidecar.sh": "c502bb5c8ba8346c722a8f5274f2274bb325a934", + "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", + "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" + } + }, + { + "kind": "ci", + "version": 1, + "files": { + ".github/workflows/ci.yml": "dc5496ad7e6a146a94fbe8dfeaa3b73461feafbc", + ".github/actions/setup-project-bun/action.yml": "cf28f6f9d4385d69784f4e3610914f6952ce789d", + ".github/scripts/release-evidence.cjs": "c4cd8c762abbfc1c0674937535c5a36b9cf705fd", + ".github/scripts/release-evidence.test.cjs": "de30ad37105cf0e7dd8c538efb5ee1cfafe02990", + "scripts/ci/select-test-lane.sh": "b25836cb55f962d1ffd9e68ed00f45f8b45becc8", + "scripts/ci/windows-sensitive-diff.sh": "03495f256c671aff57e750febffe19ca7face6a1", + "scripts/ci/run-bun-test-batches.sh": "f8aa18e80aff1c95c584cd2c879dfbb6806c11fb", + "scripts/ci/bun-crash-signatures.sh": "d34662ddd07864d97bed909135095db33fe42c9e", + "scripts/ci/bun-install-frozen.sh": "e6d7bc3aec044e12297c7c4c04015a33df0eb22b", + "scripts/ci/docker-smoke.ts": "a8424f93986a31625a0a236cdab5667db67b3405", + "scripts/ci/test-durations.tsv": "6ff82545debbd95286928a7a99abfdeb10020a7a", + "scripts/ci/test-durations.windows.tsv": "18c65823d855bb592cdd59bd102399bd9ead3967", + "scripts/test.ts": "363bf072c4b689478fdbb76a61bf4eadbc054548", + "scripts/lib/test-runner-bun.ts": "8763806104a851dbdefeea8cffe8d7dd8d1c8da1", + "scripts/test-temp-lock.ts": "ee83fb254b728de119028f52b9894bf52d7aea15", + "scripts/ci/run-changed-tests.sh": "9a54d3a5db9849bcbe0dd766954a840185419fa5", + "scripts/keyring-smoke.ts": "cd3be07ad57796172696dfa053ea1fb129f29365", + "scripts/privacy-scan.ts": "86314e891b45e49739b94c76cddd9e4cc2d4340f", + "scripts/structure-ssot.ts": "40ba68401df9b77156afed66d6a11c61df8e9a41", + "scripts/generate-ocx-skill-surface.ts": "155e866656ba2d71e8fd48adda857451caf16322", + "desktop/scripts/prepare-sidecar.ts": "20f7eec61f73635ea681e25f13dd850b62bb41e2", + "desktop/scripts/build-widget.sh": "38e0eb9d713804b603aa75c32383aa18ef01d06c", + "desktop/scripts/verify-macos-runtime.sh": "43f1a31375c6f6fe7dcafb9d96c231b98d1f6ba4", + "desktop/scripts/verify-linux-sidecar.sh": "c502bb5c8ba8346c722a8f5274f2274bb325a934", + "desktop/scripts/appimage-patchelf.py": "63e78ef74160bc48fbcdd882065f1d416db6d13c", + "desktop/scripts/linux-packaged-e2e.ts": "9ca92352668ff881c85caea44a9b4747bedc7e2d" + } + } +] diff --git a/.github/scripts/release-evidence.cjs b/.github/scripts/release-evidence.cjs new file mode 100644 index 00000000000..c4cd8c762ab --- /dev/null +++ b/.github/scripts/release-evidence.cjs @@ -0,0 +1,364 @@ +'use strict'; + +// Policy is loaded from the trusted release workflow revision, not the candidate. +// It pins the workflow, local setup action and enumerated CI job/test-selection +// helpers; it does not pin the candidate's whole source tree or every transitive +// test/build dependency. Those inputs, package scripts and repository writers are +// trusted through normal dev review. This is not a defence against a writer who +// can change trusted policy. Security review must assess that boundary separately. +// After reviewing an intentional policy-path change, update its Git blob ID in +// release-evidence-policy.json with `git hash-object --no-filters `, then +// run `node .github/scripts/release-evidence.cjs policy-check` and the evidence +// tests. Retain old approved entries if historical revisions must remain usable. +const APPROVED_POLICIES = require('./release-evidence-policy.json'); +const { execFileSync } = require('node:child_process'); +const { readFileSync } = require('node:fs'); +const { createHash } = require('node:crypto'); +const { resolve } = require('node:path'); +const REPOSITORY_ID = 1273824907; +const FULL_SHA = /^[0-9a-f]{40}$/; +const CI_JOBS = Object.freeze([ + 'changes', 'select windows runner', 'storage policy', 'api usage', 'gates', + ...Array.from({length: 5}, (_, i) => `test ${i + 1}/5`), + ...Array.from({length: 4}, (_, i) => `macos ${i + 1}/4`), + 'macos control', ...Array.from({length: 11}, (_, i) => `windows ${i + 1}/11`), + 'keyring ubuntu', 'keyring windows', 'keyring macos', 'docker smoke', + 'docs site build', 'structure gate', 'macos widget + bundle', 'desktop shell', + ...['ubuntu-latest', 'windows-latest', 'macos-latest'].flatMap(os => [ + `setup action ${os}`, `remote helper ${os}`, `npm-global ${os}`, + ]), 'ci', +]); + +function sha(value) { + if (typeof value !== 'string' || !FULL_SHA.test(value)) throw new Error('expected full lowercase commit SHA'); + return value; +} +function serviceJobs(sourceSha) { + sha(sourceSha); + return ['validate source', ...['linux-systemd', 'macos-launchd', 'windows-schtasks'].map(n => `${n} ${sourceSha}`)]; +} +function validateJobs(jobs, required) { + if (!Array.isArray(jobs)) throw new Error('invalid jobs response'); + const seen = new Set(); + for (const job of jobs) { + if (!job || typeof job.name !== 'string' || seen.has(job.name)) throw new Error('duplicate or malformed job'); + seen.add(job.name); + } + for (const name of required) { + const job = jobs.find(j => j.name === name); + if (!job || job.status !== 'completed' || job.conclusion !== 'success') throw new Error(`missing or unsuccessful job: ${name}`); + } + return true; +} +function apiClient(repository, token) { + if (repository !== 'lidge-jun/opencodex' || typeof token !== 'string' || !token) throw new Error('expected GH_TOKEN and GITHUB_REPOSITORY=lidge-jun/opencodex'); + const prefix = `https://api.github.com/repos/${repository}`; + return async path => { + if (path !== '' && (!path.startsWith('/') || path.startsWith('//'))) throw new Error('invalid API path'); + const response = await fetch(prefix + path, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, + redirect: 'error', + }); + if (!response.ok) throw new Error(`GitHub API request failed (${response.status})`); + return response.json(); + }; +} +async function pages(api, path, key, maxTotal = 1000) { + const collected = []; + let total; + for (let page = 1; ; page++) { + const data = await api(`${path}${path.includes('?') ? '&' : '?'}per_page=100&page=${page}`); + if (!data || !Number.isSafeInteger(data.total_count) || data.total_count < 0 || !Array.isArray(data[key]) || data[key].length > 100) throw new Error('invalid paginated response'); + if (total === undefined) total = data.total_count; + if (total !== data.total_count) throw new Error('pagination changed during lookup'); + if (total > maxTotal) throw new Error('pagination coverage exceeds the safe limit'); + if (data[key].length !== Math.min(100, total - collected.length)) throw new Error('incomplete pagination'); + collected.push(...data[key]); + if (collected.length === total) return collected; + if (!data[key].length || collected.length > total) throw new Error('incomplete pagination'); + } +} +async function treeEntry(api, commitSha, pathname) { + const commit = await api(`/git/commits/${sha(commitSha)}`); + let tree = commit?.tree?.sha; + if (!FULL_SHA.test(tree || '')) throw new Error('invalid commit tree'); + const parts = pathname.split('/'); + for (let i = 0; i < parts.length; i++) { + const data = await api(`/git/trees/${tree}`); + if (data.truncated || !Array.isArray(data.tree)) throw new Error('incomplete tree'); + const matches = data.tree.filter(entry => entry.path === parts[i]); + if (matches.length !== 1 || !FULL_SHA.test(matches[0].sha || '')) throw new Error('missing policy path'); + if (i === parts.length - 1) return matches[0]; + if (matches[0].type !== 'tree') throw new Error('invalid policy path'); + tree = matches[0].sha; + } + throw new Error('invalid policy path'); +} +async function assertPolicy(api, run, kind, policies) { + const candidates = policies.filter(p => p.kind === kind && p.version === 1 && p.files && typeof p.files === 'object'); + const workflowPath = `.github/workflows/${kind === 'ci' ? 'ci.yml' : 'service-lifecycle.yml'}`; + for (const policy of candidates) { + if (!Object.hasOwn(policy.files, workflowPath)) continue; + let matches = true; + for (const [path, expected] of Object.entries(policy.files)) { + let entry; + try { entry = await treeEntry(api, run.head_sha, path); } + catch (error) { + if (error.message === 'missing policy path') { matches = false; break; } + throw error; + } + if (!FULL_SHA.test(expected) || entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode) || entry.sha !== expected) { matches = false; break; } + } + if (matches) return; + } + throw new Error(`unapproved ${kind} workflow or helper policy`); +} +const SERVICE_PATH = /^(?:src\/service\.ts|src\/service\/.+|desktop\/.+|src\/cli\.ts|src\/cli\/index\.ts|src\/lib\/bun-runtime\.ts|package\.json|bun\.lock|\.github\/workflows\/service-lifecycle\.yml|\.github\/workflows\/release\.yml)$/; +function serviceChangeRequired(paths) { + if (!Array.isArray(paths)) throw new Error('expected a list of changed paths'); + for (const path of paths) { + if (typeof path !== 'string' || !path || path.startsWith('/') || path.includes('\0') || path.split('/').some(part => !part || part === '.' || part === '..')) throw new Error('invalid changed path'); + } + return paths.some(path => SERVICE_PATH.test(path)); +} +function git(args) { + try { + return execFileSync('git', args, { encoding: 'buffer', maxBuffer: 100 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + } catch { + throw new Error('git could not verify the requested service range'); + } +} +function serviceRange(candidate, baseline) { + sha(candidate); + if (baseline !== '--initial') sha(baseline); + for (const commit of baseline === '--initial' ? [candidate] : [candidate, baseline]) { + if (git(['cat-file', '-t', commit]).toString('utf8').trim() !== 'commit') throw new Error('service range requires commit objects'); + } + const output = baseline === '--initial' + ? git(['ls-tree', '-r', '-z', '--name-only', '--full-tree', candidate]) + : (git(['merge-base', '--is-ancestor', baseline, candidate]), git(['diff', '--name-only', '-z', '--no-ext-diff', '--no-renames', baseline, candidate, '--'])); + let decoded; + try { decoded = new TextDecoder('utf-8', {fatal:true}).decode(output); } + catch { throw new Error('service range contains a non-UTF-8 path'); } + if (decoded && !decoded.endsWith('\0')) throw new Error('incomplete service range'); + return {required: serviceChangeRequired(decoded ? decoded.slice(0, -1).split('\0') : [])}; +} +function checkPolicyFiles(policies, root) { + const current = new Map(policies.map(policy => [policy.kind, policy])); + for (const kind of ['ci', 'service']) if (!current.has(kind)) throw new Error(`missing ${kind} evidence policy`); + for (const policy of current.values()) { + for (const [name, expected] of Object.entries(policy.files)) { + const bytes = readFileSync(resolve(root, name)); + const actual = createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'); + if (actual !== expected) throw new Error(`policy fingerprint mismatch: ${name}`); + } + } + return true; +} +function validateRun(run, workflow, kind, sourceSha) { + if (!run || !Number.isSafeInteger(run.id) || run.id <= 0 || !Number.isSafeInteger(run.run_attempt) || run.run_attempt < 1 || + run.workflow_id !== workflow.id || run.repository?.id !== REPOSITORY_ID || run.head_repository?.id !== REPOSITORY_ID || + run.event !== 'workflow_dispatch' || run.head_branch !== 'dev' || run.status !== 'completed' || run.conclusion !== 'success') { + throw new Error('unqualified run metadata'); + } + sha(run.head_sha); + if (kind === 'ci' && (run.head_sha !== sourceSha || run.display_title !== `ocx-ci-v1 lane=all sha=${sourceSha}`)) throw new Error('CI lane or source mismatch'); +} +async function ancestor(api, base, head) { + sha(base); sha(head); + const data = await api(`/compare/${base}...${head}`); + if (!['ahead', 'behind', 'identical', 'diverged'].includes(data?.status)) throw new Error('invalid comparison'); + return data.status === 'ahead' || data.status === 'identical'; +} +// GitHub limits filtered workflow searches to 1,000 results. Discovery may +// inspect at most those 1,000; exact-SHA lists must fit completely in that cap. +// The compare bound and global request budget make failure explicit when a +// candidate is too old to prove newest within the available API budget. +const MAX_COMPARE_COMMITS = 200; +const MAX_API_REQUESTS = 800; +function createEvidence(rawApi, policies = APPROVED_POLICIES) { + let requestCount = 0; + const api = async path => { + if (++requestCount > MAX_API_REQUESTS) throw new Error('GitHub API request budget exceeded'); + return rawApi(path); + }; + const objects = new Map(); + const comparisons = new Map(); + async function policyApi(path) { + if (!/^\/git\/(?:commits|trees)\/[0-9a-f]{40}$/.test(path)) return api(path); + if (!objects.has(path)) objects.set(path, api(path).catch(error => { objects.delete(path); throw error; })); + return objects.get(path); + } + async function isAncestor(base, head) { + sha(base); sha(head); + if (base === head) return true; + const key = `${base}...${head}`; + if (!comparisons.has(key)) comparisons.set(key, ancestor(api, base, head)); + return comparisons.get(key); + } + async function workflow(kind) { + const filename = kind === 'ci' ? 'ci.yml' : 'service-lifecycle.yml'; + const [repo, wf] = await Promise.all([api(''), api(`/actions/workflows/${filename}`)]); + if (repo?.id !== REPOSITORY_ID || repo.full_name !== 'lidge-jun/opencodex' || wf?.path !== `.github/workflows/${filename}` || !Number.isSafeInteger(wf.id)) throw new Error('repository or workflow identity mismatch'); + return wf; + } + async function eligible(run, wf, kind, sourceSha) { + validateRun(run, wf, kind, sourceSha); + await assertPolicy(policyApi, run, kind, policies); + const jobs = await pages(api, `/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs`, 'jobs'); + validateJobs(jobs, kind === 'ci' ? CI_JOBS : serviceJobs(sourceSha)); + } + async function qualifies(run, wf, kind, candidate, dev) { + try { + await eligible(run, wf, kind, candidate); + } catch (error) { + // API, pagination, resource limits and unknown errors are never evidence + // that a run is ineligible; stop instead of skipping them. + if (/^(unqualified run metadata|CI lane or source mismatch|unapproved .* workflow or helper policy|missing or unsuccessful job:|duplicate or malformed job|expected full lowercase commit SHA)/.test(error.message)) return false; + throw error; + } + return isAncestor(run.head_sha, dev); + } + function listPath(wf, head) { + const query = new URLSearchParams({event:'workflow_dispatch', branch:'dev', status:'success'}); + if (head) query.set('head_sha', sha(head)); + return `/actions/workflows/${wf.id}/runs?${query}`; + } + function signature(runs) { + const ids = new Set(); + return JSON.stringify(runs.map(run => { + if (!run || !Number.isSafeInteger(run.id) || run.id <= 0 || ids.has(run.id)) throw new Error('duplicate or malformed run'); + ids.add(run.id); + return [run.id,run.run_attempt,run.status,run.conclusion,run.head_sha,run.head_branch,run.event,run.workflow_id,run.repository?.id,run.head_repository?.id,run.display_title]; + })); + } + const snapshots = []; + async function exactRuns(wf, head) { + const path = listPath(wf, head); + const runs = await pages(api, path, 'workflow_runs'); + const stamp = signature(runs); + snapshots.push(async () => { + if (signature(await pages(api, path, 'workflow_runs')) !== stamp) throw new Error('run pagination changed during lookup'); + }); + return runs; + } + async function findEligible(runs, wf, kind, source, dev) { + for (const run of runs) { + const candidate = kind === 'ci' ? run.head_sha : source; + if (!FULL_SHA.test(candidate || '') || (kind === 'ci' && source && candidate !== source)) continue; + if (await qualifies(run, wf, kind, candidate, dev) && await isAncestor(candidate, dev)) return {sourceSha:candidate,ciRunId:run.id}; + } + return undefined; + } + async function discover(wf, dev) { + const path = listPath(wf); + const observed = []; + let total; + for (let page = 1; page <= 10; page++) { + const data = await api(`${path}&per_page=100&page=${page}`); + if (!data || !Number.isSafeInteger(data.total_count) || data.total_count < 0 || !Array.isArray(data.workflow_runs)) throw new Error('invalid paginated response'); + if (total === undefined) total = data.total_count; + if (total !== data.total_count) throw new Error('run pagination changed during lookup'); + const expected = Math.min(100, Math.max(0, total - observed.length)); + if (data.workflow_runs.length !== expected) throw new Error('incomplete run pagination'); + observed.push(...data.workflow_runs); + signature(observed); + const found = await findEligible(data.workflow_runs, wf, 'ci', undefined, dev); + if (found) { + const count = page, stamp = signature(observed), initialTotal = total; + snapshots.push(async () => { + const again = []; + for (let n = 1; n <= count; n++) { + const result = await api(`${path}&per_page=100&page=${n}`); + if (result?.total_count !== initialTotal || !Array.isArray(result.workflow_runs)) throw new Error('run pagination changed during lookup'); + again.push(...result.workflow_runs); + } + if (signature(again) !== stamp) throw new Error('run pagination changed during lookup'); + }); + return found; + } + if (observed.length === total) throw new Error('no qualifying ci evidence'); + } + throw new Error('run pagination coverage exceeds the safe limit'); + } + async function commitsSince(base, head) { + if (base === head) return []; + const collected = [], seen = new Set(); + let total; + for (let page = 1; ; page++) { + const data = await api(`/compare/${base}...${head}?per_page=100&page=${page}`); + if (data?.status !== 'ahead' || !Number.isSafeInteger(data.total_commits) || data.total_commits < 1 || !Array.isArray(data.commits)) throw new Error('invalid commit comparison'); + if (total === undefined) total = data.total_commits; + if (total !== data.total_commits) throw new Error('commit pagination changed during lookup'); + if (total > MAX_COMPARE_COMMITS) throw new Error('commit comparison coverage exceeds the safe limit'); + if (data.commits.length !== Math.min(100, total-collected.length)) throw new Error('incomplete commit pagination'); + for (const commit of data.commits) { + const value = sha(commit?.sha); + if (value === base || seen.has(value)) throw new Error('duplicate or malformed comparison commit'); + seen.add(value); collected.push(value); + } + if (collected.length === total) { + if (!seen.has(head)) throw new Error('incomplete commit comparison'); + return collected; + } + } + } + async function select(kind, sourceSha) { + if (!['ci','service'].includes(kind)) throw new Error('unknown evidence kind'); + if (sourceSha !== undefined) sha(sourceSha); + if (kind === 'service' && !sourceSha) throw new Error('service requires source SHA'); + if (!policies.some(policy => policy.kind === kind && policy.version === 1)) throw new Error(`no approved ${kind} evidence policy`); + const wf = await workflow(kind); + const dev = sha((await api('/commits/dev'))?.sha); + if (sourceSha && !await isAncestor(sourceSha, dev)) throw new Error('source is not a dev ancestor'); + let found; + if (sourceSha) { + // A service dispatch runs the workflow from dev and may test an older + // source; its head_sha is the workflow revision, not the tested source. + const runs = await exactRuns(wf, kind === 'ci' ? sourceSha : undefined); + found = await findEligible(runs, wf, kind, sourceSha, dev); + } else { + found = await discover(wf, dev); + const candidates = [found]; + for (const commit of await commitsSince(found.sourceSha, dev)) { + const runs = await exactRuns(wf, commit); + const candidate = await findEligible(runs, wf, 'ci', commit, dev); + if (candidate) candidates.push(candidate); + } + let newest; + for (const candidate of candidates) { + let includesAll = true; + for (const other of candidates) if (!await isAncestor(other.sourceSha, candidate.sourceSha)) { includesAll = false; break; } + if (includesAll) { + if (newest && newest.sourceSha !== candidate.sourceSha) throw new Error('ambiguous qualifying sources'); + newest = candidate; + } + } + if (!newest) throw new Error('ambiguous qualifying sources'); + found = newest; + } + if (!found) throw new Error(`no qualifying ${kind} evidence`); + for (const confirm of snapshots) await confirm(); + if (sha((await api('/commits/dev'))?.sha) !== dev) throw new Error('dev changed during evidence lookup'); + return found; + } + return { select }; +} +async function main(args) { + if (args[0] === 'service-range' && args.length === 3) { + process.stdout.write(`${JSON.stringify(serviceRange(args[1], args[2]))}\n`); + return; + } + if (args[0] === 'policy-check' && args.length === 1) { + checkPolicyFiles(APPROVED_POLICIES, resolve(__dirname, '../..')); + process.stdout.write('Evidence policy fingerprints match the working tree.\n'); + return; + } + if (!['resolve', 'check', 'service'].includes(args[0]) || args.length > 2 || (args[0] !== 'resolve' && args.length !== 2)) throw new Error('usage: release-evidence.cjs resolve [fullSha] | check | service | service-range | policy-check'); + if (args[1] !== undefined) sha(args[1]); + const evidence = createEvidence(apiClient(process.env.GITHUB_REPOSITORY, process.env.GH_TOKEN)); + const result = await evidence.select(args[0] === 'service' ? 'service' : 'ci', args[1]); + if (args[0] === 'resolve') process.stdout.write(`${JSON.stringify(result)}\n`); +} +if (require.main === module) main(process.argv.slice(2)).catch(error => { process.stderr.write(`release evidence: ${error.message}\n`); process.exitCode = 1; }); +module.exports = { CI_JOBS, serviceJobs, validateJobs, createEvidence, pages, validateRun, apiClient, serviceChangeRequired, serviceRange, checkPolicyFiles }; diff --git a/.github/scripts/release-evidence.test.cjs b/.github/scripts/release-evidence.test.cjs new file mode 100644 index 00000000000..de30ad37105 --- /dev/null +++ b/.github/scripts/release-evidence.test.cjs @@ -0,0 +1,372 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = path.resolve(__dirname, '../..'); +const SHA = 'a'.repeat(40); + +test('full dispatch fixes path scopes and identifies its lane and SHA', () => { + const ci = fs.readFileSync(path.join(root, '.github/workflows/ci.yml'), 'utf8'); + assert.match(ci, /run-name:.*ocx-ci-v1.*github\.sha/); + for (const scope of ['gui', 'packaging', 'docs', 'structure', 'ci', 'desktop', 'native', 'setup_action', 'remote_helper']) { + assert.match(ci, new RegExp(`workflow_dispatch[^\\n]*lane[^\\n]*all[^\\n]*steps\\.filter\\.outputs\\.${scope}`)); + } +}); + +test('CI runs all release and Pages Node contract suites', () => { + const ci = fs.readFileSync(path.join(root, '.github/workflows/ci.yml'), 'utf8'); + const step = ci.split('\n').find(line => line.includes('node --test') && line.includes('release-evidence.test.cjs')); + assert.ok(step); + for (const suite of ['release-dispatch-guard.test.cjs','release-evidence.test.cjs','release-history.test.cjs','release-resume.test.cjs','deploy-docs.test.cjs']) assert.ok(step.includes(suite), suite); +}); + +test('service dispatch validates and binds the same full SHA in each job and checkout', () => { + const service = fs.readFileSync(path.join(root, '.github/workflows/service-lifecycle.yml'), 'utf8'); + assert.match(service, /source_sha:/); + assert.match(service, /\[0-9a-f\]\{40\}/); + for (const name of ['linux-systemd', 'macos-launchd', 'windows-schtasks']) { + assert.ok(service.includes('name: ' + name + ' ${{ github.event.inputs.source_sha')); + } + assert.equal((service.match(/ref: \$\{\{ github\.event_name == 'workflow_dispatch' && github\.event\.inputs\.source_sha \|\| github\.sha \}\}/g) || []).length, 3); +}); + +test('run attempt graph fails on missing, skipped and duplicate legs', () => { + const { validateJobs, CI_JOBS } = require('./release-evidence.cjs'); + const jobs = CI_JOBS.map(name => ({name, status:'completed', conclusion:'success'})); + assert.equal(validateJobs(jobs, CI_JOBS), true); + assert.throws(() => validateJobs(jobs.slice(1), CI_JOBS)); + assert.throws(() => validateJobs([...jobs, jobs[0]], CI_JOBS)); + assert.throws(() => validateJobs(jobs.map((j,i) => i ? j : {...j, conclusion:'skipped'}), CI_JOBS)); +}); + +test('service graph binds every leg to the exact tested SHA', () => { + const { validateJobs, serviceJobs } = require('./release-evidence.cjs'); + const names = serviceJobs(SHA); + const jobs = names.map(name => ({name, status:'completed', conclusion:'success'})); + assert.equal(validateJobs(jobs, names), true); + assert.throws(() => validateJobs(jobs, serviceJobs('b'.repeat(40)))); + assert.throws(() => serviceJobs('abc')); +}); + +test('authenticated client accepts repository root without allowing alternate hosts', async () => { + const { apiClient } = require('./release-evidence.cjs'); + const original = global.fetch; + const requests = []; + global.fetch = async (url, options) => { requests.push({url, options}); return {ok:true, json:async () => ({id:1273824907})}; }; + try { + const api = apiClient('lidge-jun/opencodex', 'token'); + assert.deepEqual(await api(''), {id:1273824907}); + assert.equal(requests[0].url, 'https://api.github.com/repos/lidge-jun/opencodex'); + await assert.rejects(api('//example.org'), /invalid API path/); + } finally { global.fetch = original; } +}); + +test('run identity rejects forged titles, wrong repository, branch, event and attempt', () => { + const { validateRun } = require('./release-evidence.cjs'); + const workflow = {id:42}; + const run = {id:7, workflow_id:42, repository:{id:1273824907}, head_repository:{id:1273824907}, event:'workflow_dispatch', head_branch:'dev', status:'completed', conclusion:'success', run_attempt:1, head_sha:SHA, display_title:`ocx-ci-v1 lane=all sha=${SHA}`}; + assert.doesNotThrow(() => validateRun(run, workflow, 'ci', SHA)); + for (const change of [{event:'push'}, {head_branch:'main'}, {conclusion:'failure'}, {run_attempt:0}, {repository:{id:2}}, {head_repository:{id:2}}, {display_title:`ocx-ci-v1 lane=release-gates sha=${SHA}`}, {display_title:`ocx-ci-v1 lane=windows-native-claim sha=${SHA}`}, {head_sha:'b'.repeat(40)}]) { + assert.throws(() => validateRun({...run, ...change}, workflow, 'ci', SHA)); + } +}); + +test('pagination reads all pages and refuses truncated or changing totals', async () => { + const { pages } = require('./release-evidence.cjs'); + const calls = []; + const api = async url => { calls.push(url); return url.endsWith('page=1') ? {total_count:101, jobs:Array.from({length:100}, (_,i) => i)} : {total_count:101, jobs:[100]}; }; + assert.equal((await pages(api, '/runs/1/jobs', 'jobs')).length, 101); + assert.equal(calls.length, 2); + await assert.rejects(pages(async () => ({total_count:2,jobs:[]}), '/runs/1/jobs', 'jobs'), /incomplete pagination/); + await assert.rejects(pages(async url => url.endsWith('page=1') ? {total_count:101,jobs:Array(100).fill(1)} : {total_count:102,jobs:[1,2]}, '/runs/1/jobs', 'jobs'), /pagination changed/); +}); + +test('selector authenticates policy and chooses the descendant, independent of run order', async () => { + const { createEvidence, CI_JOBS } = require('./release-evidence.cjs'); + const older = 'b'.repeat(40), newer = 'c'.repeat(40), tree = 'd'.repeat(40), blob = 'e'.repeat(40); + const mkRun = (id, head) => ({id, workflow_id:42, repository:{id:1273824907}, head_repository:{id:1273824907}, event:'workflow_dispatch', head_branch:'dev', status:'completed', conclusion:'success', run_attempt:2, head_sha:head, display_title:`ocx-ci-v1 lane=all sha=${head}`}); + const jobs = CI_JOBS.map(name => ({name, status:'completed', conclusion:'success'})); + const calls = []; + const api = async url => { + calls.push(url); + if (url === '') return {id:1273824907,full_name:'lidge-jun/opencodex'}; + if (url === '/actions/workflows/ci.yml') return {id:42,path:'.github/workflows/ci.yml'}; + if (url === '/commits/dev') return {sha:newer}; + if (url.startsWith('/compare/')) { const [base,head] = url.slice(9).split('...'); return {status:base === head ? 'identical' : base === older && head === newer ? 'ahead' : 'behind'}; } + if (url.startsWith('/actions/workflows/42/runs?')) return {total_count:2,workflow_runs:[mkRun(1,newer),mkRun(2,older)]}; + if (url.startsWith('/git/commits/')) return {tree:{sha:tree}}; + if (url === `/git/trees/${tree}`) return {truncated:false,tree:[{path:'.github',type:'tree',sha:tree},{path:'workflows',type:'tree',sha:tree},{path:'ci.yml',type:'blob',mode:'100644',sha:blob}]}; + if (/^\/actions\/runs\/[12]\/attempts\/2\/jobs\?/.test(url)) return {total_count:jobs.length,jobs}; + throw new Error(`unexpected API request ${url}`); + }; + const approved = [{kind:'ci',version:1,files:{'.github/workflows/ci.yml':blob}}]; + assert.deepEqual(await createEvidence(api, approved).select('ci'), {sourceSha:newer,ciRunId:1}); + assert.deepEqual(await createEvidence(api, approved).select('ci', older), {sourceSha:older,ciRunId:2}); + const historical = {kind:'ci',version:1,files:{'.github/workflows/absent.yml':blob,'.github/workflows/ci.yml':blob}}; + assert.deepEqual(await createEvidence(api, [historical, ...approved]).select('ci', older), {sourceSha:older,ciRunId:2}); + assert.ok(calls.some(url => url.includes('/attempts/2/jobs?'))); + await assert.rejects(createEvidence(api, [{kind:'ci',version:1,files:{'.github/workflows/ci.yml':'f'.repeat(40)}}]).select('ci'), /no qualifying/); + await assert.rejects(createEvidence(api, []).select('ci'), /no approved ci evidence policy/); +}); + +test('service uses setup action from the workflow revision rather than the historical candidate', () => { + const service = fs.readFileSync(path.join(root, '.github/workflows/service-lifecycle.yml'), 'utf8'); + assert.equal((service.match(/path: \.evidence-policy/g) || []).length, 3); + assert.equal((service.match(/ref: \$\{\{ github\.sha \}\}/g) || []).length, 3); + assert.equal((service.match(/uses: \.\/\.evidence-policy\/\.github\/actions\/setup-project-bun/g) || []).length, 3); +}); + +test('service evidence uses workflow revision policy and a complete current attempt for the candidate', async () => { + const { createEvidence, serviceJobs } = require('./release-evidence.cjs'); + const revision = 'b'.repeat(40), tree = 'c'.repeat(40), blob = 'd'.repeat(40); + const run = {id:9,workflow_id:43,repository:{id:1273824907},head_repository:{id:1273824907},event:'workflow_dispatch',head_branch:'dev',status:'completed',conclusion:'success',run_attempt:3,head_sha:revision}; + let jobs = serviceJobs(SHA).map(name => ({name,status:'completed',conclusion:'success'})); + const api = async url => { + if (url === '') return {id:1273824907,full_name:'lidge-jun/opencodex'}; + if (url === '/actions/workflows/service-lifecycle.yml') return {id:43,path:'.github/workflows/service-lifecycle.yml'}; + if (url === '/commits/dev') return {sha:revision}; + if (url.startsWith('/compare/')) return {status:'ahead'}; + if (url.startsWith('/actions/workflows/43/runs?')) return {total_count:1,workflow_runs:[run]}; + if (url === `/git/commits/${revision}`) return {tree:{sha:tree}}; + if (url === `/git/trees/${tree}`) return {truncated:false,tree:[{path:'.github',type:'tree',sha:tree},{path:'workflows',type:'tree',sha:tree},{path:'service-lifecycle.yml',type:'blob',mode:'100644',sha:blob}]}; + if (url.startsWith('/actions/runs/9/attempts/3/jobs?')) return {total_count:jobs.length,jobs}; + throw new Error(`unexpected API request ${url}`); + }; + const policy = [{kind:'service',version:1,files:{'.github/workflows/service-lifecycle.yml':blob}}]; + assert.deepEqual(await createEvidence(api, policy).select('service', SHA), {sourceSha:SHA,ciRunId:9}); + jobs = jobs.slice(1); + await assert.rejects(createEvidence(api, policy).select('service', SHA), /no qualifying/); + jobs = serviceJobs('e'.repeat(40)).map(name => ({name,status:'completed',conclusion:'success'})); + await assert.rejects(createEvidence(api, policy).select('service', SHA), /no qualifying/); +}); + +test('production policy pins the current workflows and job-selection helpers', () => { + const { execFileSync } = require('node:child_process'); + const policies = require('./release-evidence-policy.json'); + for (const kind of ['ci', 'service']) { + const policy = policies.filter(p => p.kind === kind && p.version === 1).at(-1); + assert.ok(policy); + const required = kind === 'ci' ? ['.github/workflows/ci.yml', '.github/actions/setup-project-bun/action.yml', 'scripts/ci/run-bun-test-batches.sh', 'scripts/ci/select-test-lane.sh', 'scripts/ci/windows-sensitive-diff.sh', 'scripts/test.ts', 'scripts/keyring-smoke.ts', 'desktop/scripts/verify-macos-runtime.sh', 'desktop/scripts/verify-linux-sidecar.sh', 'desktop/scripts/linux-packaged-e2e.ts'] : ['.github/workflows/service-lifecycle.yml', '.github/actions/setup-project-bun/action.yml']; + for (const name of required) assert.ok(policy.files[name], name); + for (const [name, digest] of Object.entries(policy.files)) { + assert.equal(execFileSync('git', ['hash-object', '--no-filters', name], {cwd:root,encoding:'utf8'}).trim(), digest, name); + } + } +}); + +test('service change detection handles initial trees, cumulative changes and deletions', () => { + const { serviceChangeRequired } = require('./release-evidence.cjs'); + assert.equal(serviceChangeRequired(['README.md', 'src/router.ts']), false); + for (const file of ['src/service.ts','src/service/daemon.ts','desktop/app/main.rs','src/cli.ts','src/cli/index.ts','src/lib/bun-runtime.ts','package.json','bun.lock','.github/workflows/service-lifecycle.yml','.github/workflows/release.yml']) { + assert.equal(serviceChangeRequired(['README.md',file]), true, file); + } + for (const file of ['src/service-other.ts','src/cli/index.ts.bak','nested/package.json']) assert.equal(serviceChangeRequired([file]), false, file); + assert.throws(() => serviceChangeRequired(null)); + assert.throws(() => serviceChangeRequired(['../src/service.ts'])); +}); + +test('service range accepts a reachable baseline or an explicit initial tree only', () => { + const { execFileSync } = require('node:child_process'); + const os = require('node:os'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ocx-service-evidence-')); + const git = (...args) => execFileSync('git', args, {cwd:dir,encoding:'utf8'}).trim(); + const script = path.join(root, '.github/scripts/release-evidence.cjs'); + const run = (...args) => JSON.parse(execFileSync(process.execPath, [script, 'service-range', ...args], {cwd:dir,encoding:'utf8',stdio:['ignore','pipe','pipe']})); + try { + git('init','-q'); git('config','user.email','fixture@localhost'); git('config','user.name','fixture'); + fs.mkdirSync(path.join(dir,'src/service'), {recursive:true}); + fs.writeFileSync(path.join(dir,'src/service/daemon.ts'),'one'); + git('add','.'); git('commit','-qm','initial'); const initial = git('rev-parse','HEAD'); + fs.writeFileSync(path.join(dir,'src/service/daemon.ts'),'two'); + git('add','.'); git('commit','-qm','service'); + fs.writeFileSync(path.join(dir,'README.md'),'other'); + git('add','.'); git('commit','-qm','later'); const later = git('rev-parse','HEAD'); + assert.deepEqual(run(initial,'--initial'), {required:true}); + assert.deepEqual(run(later,initial), {required:true}); + assert.deepEqual(run(later,later), {required:false}); + fs.unlinkSync(path.join(dir,'src/service/daemon.ts')); + git('add','-A'); git('commit','-qm','remove service'); const removed = git('rev-parse','HEAD'); + assert.deepEqual(run(removed,later), {required:true}); + assert.throws(() => run(initial,later)); + assert.throws(() => run(later)); + } finally { fs.rmSync(dir,{recursive:true,force:true}); } +}); + +test('policy approval check detects drift while retaining historical policies', () => { + const { checkPolicyFiles } = require('./release-evidence.cjs'); + const { createHash } = require('node:crypto'); + const os = require('node:os'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ocx-policy-check-')); + try { + const content = Buffer.from('reviewed'); + fs.writeFileSync(path.join(dir, 'file'),content); + const digest = createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex'); + const policies = [{kind:'ci',files:{file:'f'.repeat(40)}}, {kind:'ci',files:{file:digest}}, {kind:'service',files:{file:digest}}]; + assert.equal(checkPolicyFiles(policies,dir), true); + fs.writeFileSync(path.join(dir,'file'),'changed'); + assert.throws(() => checkPolicyFiles(policies,dir), /fingerprint mismatch/); + assert.throws(() => checkPolicyFiles(policies.slice(0,2),dir), /missing service/); + } finally { fs.rmSync(dir,{recursive:true,force:true}); } +}); + +test('default production policy accepts its exact blobs and rejects a changed helper', async () => { + const { createEvidence, CI_JOBS } = require('./release-evidence.cjs'); + const policy = require('./release-evidence-policy.json').filter(p => p.kind === 'ci').at(-1); + const run = {id:88, workflow_id:42, repository:{id:1273824907},head_repository:{id:1273824907},event:'workflow_dispatch',head_branch:'dev',status:'completed',conclusion:'success',run_attempt:1,head_sha:SHA,display_title:`ocx-ci-v1 lane=all sha=${SHA}`}; + const jobs = CI_JOBS.map(name => ({name,status:'completed',conclusion:'success'})); + const trees = new Map(); + const directorySha = path => require('node:crypto').createHash('sha1').update(path).digest('hex'); + const treeFor = path => { const id = directorySha(path); if (!trees.has(id)) trees.set(id, []); return trees.get(id); }; + treeFor(''); + for (const [file, id] of Object.entries(policy.files)) { + const parts = file.split('/'); let current = ''; + for (const part of parts.slice(0,-1)) { + const next = current ? `${current}/${part}` : part; + if (!treeFor(current).some(e => e.path === part)) treeFor(current).push({path:part,type:'tree',mode:'040000',sha:directorySha(next)}); + treeFor(next); current = next; + } + treeFor(current).push({path:parts.at(-1),type:'blob',mode:'100644',sha:id}); + } + const api = async url => { + if (url === '') return {id:1273824907,full_name:'lidge-jun/opencodex'}; + if (url === '/actions/workflows/ci.yml') return {id:42,path:'.github/workflows/ci.yml'}; + if (url === '/commits/dev') return {sha:SHA}; + if (url.startsWith('/compare/')) return {status:'identical'}; + if (url.startsWith('/actions/workflows/42/runs?')) return {total_count:1,workflow_runs:[run]}; + if (url === `/git/commits/${SHA}`) return {tree:{sha:directorySha('')}}; + if (url.startsWith('/git/trees/')) return {truncated:false,tree:trees.get(url.slice('/git/trees/'.length))}; + if (url.startsWith('/actions/runs/88/attempts/1/jobs?')) return {total_count:jobs.length,jobs}; + throw new Error(`unexpected API request ${url}`); + }; + assert.deepEqual(await createEvidence(api).select('ci', SHA), {sourceSha:SHA,ciRunId:88}); + const script = treeFor('scripts/ci').find(e => e.path === 'run-bun-test-batches.sh'); + script.sha = 'f'.repeat(40); + await assert.rejects(createEvidence(api).select('ci', SHA), /no qualifying/); +}); + +function boundedFixture({ runs, order, race = false, raceState = false, devMoves = false, divergent = [], history = ['1','2','3'].map(x => x.repeat(40)) }) { + const { CI_JOBS } = require('./release-evidence.cjs'); + const tree = 'd'.repeat(40), blob = 'e'.repeat(40), calls = []; + let exactReads = 0; + const readsByHead = new Map(); + let devReads = 0; + const api = async url => { + calls.push(url); + if (url === '') return {id:1273824907,full_name:'lidge-jun/opencodex'}; + if (url === '/actions/workflows/ci.yml') return {id:42,path:'.github/workflows/ci.yml'}; + if (url === '/commits/dev') return {sha:devMoves && ++devReads > 1 ? 'f'.repeat(40) : history.at(-1)}; + if (url.startsWith('/compare/')) { + const parsed = new URL(url, 'https://fixture.invalid'); + const [base, head] = parsed.pathname.slice(9).split('...'); + const a=history.indexOf(base), b=history.indexOf(head); + const status = a===b ? 'identical' : divergent.includes(base) && divergent.includes(head) ? 'diverged' : a ({sha})) : []; + const page = Number(parsed.searchParams.get('page') || 1), size = Number(parsed.searchParams.get('per_page') || 100); + return {status,total_commits:commits.length,commits:commits.slice((page-1)*size,page*size)}; + } + if (url.startsWith('/actions/workflows/42/runs?')) { + const query = new URL(url, 'https://fixture.invalid').searchParams; + if (query.get('event') !== 'workflow_dispatch' || query.get('branch') !== 'dev') throw new Error('unfiltered 25000-run history'); + const head = query.get('head_sha'); + if (head) { exactReads++; readsByHead.set(head, (readsByHead.get(head) || 0) + 1); } + let selected = runs.map(r => raceState && head && readsByHead.get(head) > 1 && r.status === 'in_progress' ? {...r,status:'completed',conclusion:'success'} : r) + .filter(r => (!head || r.head_sha === head) && (!query.has('status') || r.conclusion === query.get('status'))); + if (order) selected = [...selected].sort((a,b) => order.indexOf(a.id)-order.indexOf(b.id)); + const page=Number(query.get('page') || 1), size=Number(query.get('per_page') || 100); + const total_count = selected.length + (race && head && exactReads > 1 ? 1 : 0); + return {total_count,workflow_runs:selected.slice((page-1)*size,page*size)}; + } + if (url.startsWith('/git/commits/')) return {tree:{sha:tree}}; + if (url === `/git/trees/${tree}`) return {truncated:false,tree:[{path:'.github',type:'tree',sha:tree},{path:'workflows',type:'tree',sha:tree},{path:'ci.yml',type:'blob',mode:'100644',sha:blob}]}; + if (/^\/actions\/runs\/\d+\/attempts\/1\/jobs\?/.test(url)) return {total_count:CI_JOBS.length,jobs:CI_JOBS.map(name => ({name,status:'completed',conclusion:'success'}))}; + throw new Error(`unexpected API request ${url}`); + }; + const makeRun = (id, source, conclusion='success') => ({id,workflow_id:42,repository:{id:1273824907},head_repository:{id:1273824907},event:'workflow_dispatch',head_branch:'dev',status:'completed',conclusion,run_attempt:1,head_sha:source,display_title:`ocx-ci-v1 lane=all sha=${source}`}); + return {api,calls,history,makeRun,policies:[{kind:'ci',version:1,files:{'.github/workflows/ci.yml':blob}}]}; +} + +test('exact SHA filters 25000 unrelated runs on the server', async () => { + const {createEvidence} = require('./release-evidence.cjs'); + const data = boundedFixture({runs:[]}); + const old = data.history[0]; + const run = data.makeRun(7,old); + const fixture = boundedFixture({runs:[run]}); + assert.deepEqual(await createEvidence(fixture.api,fixture.policies).select('ci',old), {sourceSha:old,ciRunId:7}); + assert.ok(fixture.calls.filter(x=>x.startsWith('/actions/workflows/42/runs?')).every(x=>new URL(x,'https://fixture.invalid').searchParams.get('head_sha')===old)); +}); + +test('newest selection proves ancestry independently of run completion ordering', async () => { + const {createEvidence} = require('./release-evidence.cjs'); + const seed = boundedFixture({runs:[]}), [older,,newer] = seed.history; + const fixture = boundedFixture({runs:[seed.makeRun(1,older),seed.makeRun(2,newer)],order:[1,2]}); + assert.deepEqual(await createEvidence(fixture.api,fixture.policies).select('ci'), {sourceSha:newer,ciRunId:2}); + assert.ok(fixture.calls.some(x=>x.startsWith('/compare/') && x.includes('per_page='))); +}); + +test('newer failed runs do not hide an older qualifying commit', async () => { + const {createEvidence} = require('./release-evidence.cjs'); + const seed = boundedFixture({runs:[]}), [older,,newer] = seed.history; + const fixture = boundedFixture({runs:[seed.makeRun(2,newer,'failure'),seed.makeRun(1,older)],order:[2,1]}); + assert.deepEqual(await createEvidence(fixture.api,fixture.policies).select('ci'), {sourceSha:older,ciRunId:1}); +}); + +test('more than 1000 failed newer runs do not exhaust the filtered search', async () => { + const {createEvidence} = require('./release-evidence.cjs'); + const seed=boundedFixture({runs:[]}), [older,,newer]=seed.history; + const failures=Array.from({length:1101},(_,i)=>seed.makeRun(i+2,newer,'failure')); + const fixture=boundedFixture({runs:[...failures,seed.makeRun(1,older)]}); + assert.deepEqual(await createEvidence(fixture.api,fixture.policies).select('ci'), {sourceSha:older,ciRunId:1}); +}); + +test('exact SHA pagination and concurrent list changes fail closed', async () => { + const {createEvidence} = require('./release-evidence.cjs'); + const seed = boundedFixture({runs:[]}), source=seed.history[0]; + const runs=Array.from({length:101},(_,i)=>seed.makeRun(i+1,source)); + const good=boundedFixture({runs}); + assert.equal((await createEvidence(good.api,good.policies).select('ci',source)).sourceSha,source); + assert.ok(good.calls.some(x=>x.includes('page=2'))); + const racing=boundedFixture({runs,race:true}); + await assert.rejects(createEvidence(racing.api,racing.policies).select('ci',source), /pagination|changed|incomplete/i); +}); + +test('newest selection detects dev movement and a newer run completing during lookup', async () => { + const {createEvidence} = require('./release-evidence.cjs'); + const seed=boundedFixture({runs:[]}), [older,,newer]=seed.history; + const progress={...seed.makeRun(2,newer),status:'in_progress',conclusion:null}; + const racing=boundedFixture({runs:[seed.makeRun(1,older),progress],raceState:true}); + await assert.rejects(createEvidence(racing.api,racing.policies).select('ci'), /changed/i); + const moved=boundedFixture({runs:[seed.makeRun(1,older)],devMoves:true}); + await assert.rejects(createEvidence(moved.api,moved.policies).select('ci'), /dev changed/i); +}); + +test('newest selection paginates the commit comparison and refuses excessive ancestry', async () => { + const {createEvidence} = require('./release-evidence.cjs'); + const history=Array.from({length:102},(_,i)=>(i+1).toString(16).padStart(40,'0')); + const seed=boundedFixture({runs:[],history}); + const fixture=boundedFixture({runs:[seed.makeRun(1,history[0])],history}); + assert.equal((await createEvidence(fixture.api,fixture.policies).select('ci')).sourceSha, history[0]); + assert.ok(fixture.calls.some(x=>x.startsWith('/compare/') && x.includes('page=2'))); + const longHistory=Array.from({length:202},(_,i)=>(i+1).toString(16).padStart(40,'0')); + const long=boundedFixture({runs:[seed.makeRun(1,longHistory[0])],history:longHistory}); + await assert.rejects(createEvidence(long.api,long.policies).select('ci'), /coverage exceeds/); +}); + +test('newest selection refuses incomparable green ancestors', async () => { + const {createEvidence} = require('./release-evidence.cjs'); + const history=['1','2','3','4'].map(x=>x.repeat(40)); + const seed=boundedFixture({runs:[],history}); + const fixture=boundedFixture({history,divergent:[history[1],history[2]],runs:[seed.makeRun(1,history[0]),seed.makeRun(2,history[1]),seed.makeRun(3,history[2])]}); + await assert.rejects(createEvidence(fixture.api,fixture.policies).select('ci'), /ambiguous/); +}); + +test('filtered searches fail closed beyond GitHub’s 1000-result coverage', async () => { + const {createEvidence} = require('./release-evidence.cjs'); + const seed=boundedFixture({runs:[]}), source=seed.history[0]; + const runs=Array.from({length:1001},(_,i)=>({...seed.makeRun(i+1,source),display_title:'old policy'})); + const discovery=boundedFixture({runs}); + await assert.rejects(createEvidence(discovery.api,discovery.policies).select('ci'), /coverage exceeds/); + assert.equal(discovery.calls.filter(x=>x.startsWith('/actions/workflows/42/runs?')).length,10); + const exact=boundedFixture({runs}); + await assert.rejects(createEvidence(exact.api,exact.policies).select('ci',source), /coverage exceeds/); +}); diff --git a/.github/scripts/release-history.cjs b/.github/scripts/release-history.cjs new file mode 100644 index 00000000000..e2de485339b --- /dev/null +++ b/.github/scripts/release-history.cjs @@ -0,0 +1,394 @@ +'use strict'; + +/* + * The collector reads npm, authenticated GitHub and local Git under the release + * lock. Callers MUST execute this file from the trusted workflow revision, with + * a complete git checkout and repeat the check immediately before publication. + * The API's fetchImpl injection is for trusted callers and tests only; + * the CLI pins both endpoints, package/repository identity and mapping policy. + * Decision primitives require complete, fresh evidence under the release + * lock. isAncestor must + * query complete Git history and throw on an unknown object. verifyReceipt and + * verifyProvenance must cryptographically authenticate their inputs, bind the + * supplied tuple, and return the literal boolean true. Neither JSON flags nor a + * candidate-created Actions artifact are authentication. Never invoke these + * functions with callbacks or evidence controlled by a dispatch input. + * + * reconcileHistory accepts registry versions {version,gitHead,publishedAt}, + * distTags, Git tag commits {version,sha}, GitHub releases {version,tag}, signed + * receipt envelopes {payload}, candidateSha/candidateVersion/channel, + * isAncestor and verifyReceipt. This primitive requires direct ancestry. + * resume:true requires the candidate to be the already-published floor and an + * authenticated receipt; it does not derive or approve a new version. + * Input arrays must cover the entire reconciled ledger, including partial npm + * publishes. A collector must separately detect pagination and snapshot races. + * + * verifyExactResume accepts tuple/receipt/registry/provenance/tag/release/historyFloor + * plus verifyReceipt, verifyProvenance and verifyHistory. verifyHistory must + * authenticate a fresh, complete, lock-protected reconciliation including all + * npm-only publications. Receipt payload binds the complete tuple and + * asset name-to-SHA256 map; registry.sha256 must be independently calculated + * from downloaded registry tarball bytes. Existing assets are hashed from bytes. + * A null tag/release means the authenticated GitHub lookup confirmed absence. + * Publication orchestration must not call npm publish after a successful resume. + */ +const { verify, createPublicKey } = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const { readFileSync } = require('node:fs'); +const { isDeepStrictEqual } = require('node:util'); +const SHA = /^[a-f0-9]{40}$/; +const DIGEST = /^[a-f0-9]{64}$/; +const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-preview\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?)?$/; +const PACKAGE = '@bitkyc08/opencodex'; +const REPO = 'lidge-jun/opencodex'; +const REPO_ID = 1273824907; +// One-time legacy transition. The collector verifies the tag and registry SHA, +// both ancestry relationships and actual version-only tree differences at run +// time. This code-backed policy still needs independent review before cutover. +const LEGACY_MAPPINGS = Object.freeze([ + {version:'2.99.0',legacySha:'667662903a6c139524506667d12656d9ad50fad0'}, + {version:'2.99.0-preview.20261108',legacySha:'68eee1eb804445ad86fe942b734060f323eb1517'}, +]); +const LEGACY_BASELINE = '0627a2c255bc239d5748119f1d0bc4457a1c03e3'; +const LEGACY_BASELINE_VERSION = '2.100.0'; +const VERSION_PATHS = Object.freeze(['package.json','desktop/src-tauri/tauri.conf.json','desktop/src-tauri/Cargo.toml','desktop/src-tauri/Cargo.lock']); + +function must(condition, message) { if (!condition) throw new Error(message); } +function version(v) { + const m = typeof v === 'string' && VERSION.exec(v); + must(m, 'invalid release version'); + const numbers = m.slice(1).map(x => x === undefined ? null : Number(x)); + must(numbers.every(x => x === null || Number.isSafeInteger(x)), 'invalid release version'); + return numbers; +} +function compareVersions(a, b) { + const x = version(a), y = version(b); + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1; + if (x[3] === null || y[3] === null) return x[3] === y[3] ? 0 : x[3] === null ? 1 : -1; + for (let i = 3; i < 5; i++) { + if (x[i] !== y[i]) return x[i] === null ? -1 : y[i] === null ? 1 : x[i] < y[i] ? -1 : 1; + } + return 0; +} +function channelFor(v) { return version(v)[3] === null ? 'stable' : 'preview'; } +function validTuple(t) { + must(t && SHA.test(t.sourceSha || '') && SHA.test(t.workflowSha || '') && DIGEST.test(t.sha256 || '') && + Number.isSafeInteger(t.runId) && t.runId > 0 && Number.isSafeInteger(t.attempt) && t.attempt > 0 && + t.channel === channelFor(t.version), 'invalid receipt tuple'); + must(t.assets && typeof t.assets === 'object' && !Array.isArray(t.assets), 'invalid receipt assets'); + for (const [name, digest] of Object.entries(t.assets)) must(name && !/[\x00-\x1f/\\]/.test(name) && DIGEST.test(digest), 'invalid receipt asset'); +} +function sameTuple(a, b) { + validTuple(a); validTuple(b); + return ['sourceSha','workflowSha','version','channel','sha256','runId','attempt'].every(k => a[k] === b[k]) && + Object.keys(a.assets).length === Object.keys(b.assets).length && Object.entries(a.assets).every(([k,v]) => Object.hasOwn(b.assets,k) && b.assets[k] === v); +} +function assertReceipt(envelope, expected, verifyReceipt) { + must(envelope && typeof verifyReceipt === 'function' && verifyReceipt(envelope, expected) === true, 'receipt authentication failed'); + must(sameTuple(envelope.payload, expected), 'receipt tuple mismatch'); +} +function reconcileHistory(input) { + const {candidateSha, candidateVersion, channel, versions, distTags, tags, releases, receipts, isAncestor, verifyReceipt, resume = false} = input; + must(typeof resume === 'boolean', 'invalid resume mode'); + must(SHA.test(candidateSha || '') && channel === channelFor(candidateVersion), 'invalid candidate'); + must([versions,tags,releases,receipts].every(Array.isArray) && distTags && typeof distTags === 'object' && !Array.isArray(distTags) && typeof isAncestor === 'function', 'incomplete history'); + const byVersion = new Map(), byTag = new Map(), byRelease = new Map(), byReceipt = new Map(); + for (const r of versions) { + version(r?.version); must(!byVersion.has(r.version) && SHA.test(r.gitHead || ''), 'missing or duplicate registry source'); + const time = Date.parse(r.publishedAt); + must(typeof r.publishedAt === 'string' && Number.isFinite(time) && new Date(time).toISOString() === r.publishedAt, 'missing publication order'); + byVersion.set(r.version, {...r,time}); + } + for (const [name, v] of Object.entries(distTags)) { must(name && byVersion.has(v), 'unknown dist-tag version'); } + for (const t of tags) { version(t?.version); must(!byTag.has(t.version) && SHA.test(t.sha || '') && byVersion.has(t.version), 'unknown or duplicate tag'); byTag.set(t.version,t); } + for (const r of releases) { version(r?.version); must(!byRelease.has(r.version) && r.tag === `v${r.version}` && byTag.has(r.version), 'unknown or duplicate release'); byRelease.set(r.version,r); } + for (const envelope of receipts) { + const r = envelope?.payload; validTuple(r); + must(!byReceipt.has(r.version) && byVersion.has(r.version), 'unknown or duplicate receipt'); + assertReceipt(envelope, r, verifyReceipt); byReceipt.set(r.version, r); + } + const ordered = [...byVersion.values()].sort((a,b) => a.time-b.time); + for (let i = 0; i < ordered.length; i++) { + const r = ordered[i], tag = byTag.get(r.version), receipt = byReceipt.get(r.version); + if (i) must(ordered[i-1].time < r.time, 'ambiguous publication order'); + if (tag) must(tag.sha === r.gitHead, 'tag and registry source mismatch'); + if (receipt) must(receipt.sourceSha === r.gitHead && receipt.channel === channelFor(r.version), 'receipt and registry source mismatch'); + if (!tag || !byRelease.has(r.version)) must(receipt, 'npm-only publication requires authenticated receipt'); + } + for (let i = 1; i < ordered.length; i++) must(isAncestor(ordered[i-1].gitHead,ordered[i].gitHead) === true, 'publication lineage diverges'); + if (ordered.length) { + const floor = ordered.at(-1); + if (resume) { + must(candidateVersion === floor.version && candidateSha === floor.gitHead && byReceipt.has(floor.version), 'resume would roll back or lacks a receipt'); + } else { + must(ordered.every(r => compareVersions(candidateVersion, r.version) > 0), 'candidate version would regress or already exists'); + must(isAncestor(floor.gitHead,candidateSha) === true, 'candidate lineage would roll back or diverge'); + if (candidateSha === floor.gitHead) must(channel === 'stable' && channelFor(floor.version) === 'preview', 'same source only allowed for preview to stable'); + } + return {floor:{version:floor.version,sourceSha:floor.gitHead,publishedAt:floor.publishedAt}}; + } + must(!resume, 'resume requires published history'); + return {floor:null}; +} +function verifyExactResume({tuple, receipt, registry, provenance, tag, release, historyFloor, verifyReceipt, verifyProvenance, verifyHistory}) { + validTuple(tuple); assertReceipt(receipt, tuple, verifyReceipt); + must(registry && registry.version === tuple.version && registry.gitHead === tuple.sourceSha && registry.sha256 === tuple.sha256, 'registry identity or digest mismatch'); + must(typeof verifyProvenance === 'function' && verifyProvenance(provenance, tuple, registry) === true, 'provenance authentication failed'); + must(typeof verifyHistory === 'function' && verifyHistory(historyFloor) === true && historyFloor?.version === tuple.version && historyFloor.sourceSha === tuple.sourceSha, 'resume would roll back publication history'); + must(tag === null || (tag && tag.name === `v${tuple.version}` && tag.sha === tuple.sourceSha), 'existing tag mismatch'); + must(release === null || (release && tag && release.tag === tag.name && Array.isArray(release.assets)), 'existing release mismatch'); + const reuseAssets = [], seen = new Set(); + for (const asset of release?.assets || []) { + must(asset && typeof asset.name === 'string' && !seen.has(asset.name) && Object.hasOwn(tuple.assets,asset.name) && DIGEST.test(asset.sha256 || '') && tuple.assets[asset.name] === asset.sha256, 'unsafe existing asset'); + seen.add(asset.name); reuseAssets.push(asset.name); + } + return {publishNpm:false, reuseAssets:reuseAssets.sort()}; +} + +function createGit(repoPath) { + function run(args, allowNotAncestor = false, trim = true) { + const r = spawnSync('git', ['-C',repoPath,...args], {encoding:'utf8',maxBuffer:1024*1024,env:{...process.env,GIT_NO_REPLACE_OBJECTS:'1'},stdio:['ignore','pipe','pipe']}); + if (allowNotAncestor && r.status === 1) return false; + must(r.status === 0, `Git could not verify ${args[0]}; fetch complete dev and tag history`); + return trim ? r.stdout.trim() : r.stdout; + } + must(run(['rev-parse','--is-shallow-repository']) === 'false', 'Git history is shallow; fetch complete history'); + const known = new Set(), comparisons = new Map(); + function commit(sha) { + must(SHA.test(sha || ''), 'invalid Git source SHA'); + if (!known.has(sha)) { + try { run(['cat-file','-e',`${sha}^{commit}`]); } + catch { throw new Error(`Git source ${sha} is unavailable; fetch complete release and dev history and reconcile unknown sources`); } + known.add(sha); + } + } + function isAncestor(a,b) { + commit(a); commit(b); + const key = `${a}:${b}`; + if (!comparisons.has(key)) comparisons.set(key, run(['merge-base','--is-ancestor',a,b],true) !== false); + return comparisons.get(key); + } + function treeFile(sha,path) { + commit(sha); + const entry = run(['ls-tree',sha,'--',path]); + must(/^100644 blob [a-f0-9]{40}\t/.test(entry) && entry.endsWith(`\t${path}`), `legacy version file ${path} is missing or has an unsafe mode`); + const text = run(['show',`${sha}:${path}`],false,false); + must(!text.includes('\ufffd'), `legacy version file ${path} is not valid UTF-8`); + return text; + } + function changedPaths(a,b) { + commit(a); commit(b); + const output = run(['diff','--no-ext-diff','--no-renames','--name-status','-z',a,b,'--'],false,false); + const fields = output.split('\0'); + must(fields.pop() === '' && fields.length % 2 === 0, 'invalid legacy tree diff'); + const paths = []; + for (let i=0;i line.trim() === '[package]' ? i : -1).filter(i => i >= 0); + must(starts.length === 1, `ambiguous package version in ${path}`); + let end = starts[0] + 1; + while (end < lines.length && !/^\s*\[/.test(lines[end])) end++; + must(lines.slice(starts[0]+1,end).filter(line => /^\s*name\s*=\s*"opencodex-desktop"\s*$/.test(line)).length === 1, `invalid desktop package in ${path}`); + indexes = lines.map((line,i) => i > starts[0] && i < end && /^\s*version\s*=/.test(line) ? i : -1).filter(i => i >= 0); + } else { + indexes = []; + for (let i=0;i+2 0 && entries.length <= 10000, 'empty or oversized npm history requires reconciliation'); + const versions = entries.map(([v,metadata]) => { + version(v); + must(metadata?.name === PACKAGE && metadata.version === v, `npm ${v} has invalid package identity`); + const publishedAt = data.time[v], time = Date.parse(publishedAt); + must(typeof publishedAt === 'string' && Number.isFinite(time) && new Date(time).toISOString() === publishedAt, `npm ${v} has missing publication time`); + return {version:v,gitHead:metadata.gitHead ?? null,publishedAt,time}; + }).sort((a,b) => a.time-b.time); + for (let i=1;i r.version === value), `npm dist-tag ${key} names unknown version`); + if (tags.latest) must(channelFor(tags.latest) === 'stable', 'npm latest dist-tag is not stable'); + if (tags.preview) must(channelFor(tags.preview) === 'preview', 'npm preview dist-tag is not preview'); + return {versions,distTags:Object.fromEntries(Object.entries(tags).sort(([a],[b]) => a.localeCompare(b)))}; +} +function githubSnapshot(tags, releases) { + const tagMap = new Map(), releaseMap = new Map(); + for (const tag of tags) { + must(typeof tag?.name === 'string' && SHA.test(tag.commit?.sha || '') && !tagMap.has(tag.name), 'duplicate or malformed GitHub tag'); + tagMap.set(tag.name,tag.commit.sha); + } + for (const release of releases) { + must(typeof release?.tag_name === 'string' && typeof release.draft === 'boolean' && typeof release.prerelease === 'boolean' && !releaseMap.has(release.tag_name), 'duplicate or malformed GitHub release'); + releaseMap.set(release.tag_name,{draft:release.draft,prerelease:release.prerelease,published_at:release.published_at}); + } + return {tagMap,releaseMap}; +} +/** + * Read-only history gate. GH_TOKEN needs repository contents:read. Invoke from + * trusted workflow checkout with full Git history after acquiring the shared + * release lock: `node .github/scripts/release-history.cjs check + * `. Exit 0 returns JSON {floor,channels,devSha, + * versionsChecked}; any unverifiable evidence exits nonzero. Publications at + * or after the older channel head without a matching tag and published release + * stop for manual reconciliation; no receipt/provenance verifier is assumed. + * Older versions still participate in the global version and ordering checks. + * Re-run immediately before publication. This gate does not authorize + * npm publication or prove build, CI, package digest, provenance, or resume. + */ +async function collectPublicationHistory({candidateSha,candidateVersion,channel,repoPath = process.cwd(),token,fetchImpl = fetch}) { + must(typeof token === 'string' && token.length > 0 && typeof fetchImpl === 'function', 'GH_TOKEN is required for authenticated GitHub evidence'); + must(SHA.test(candidateSha || '') && channel === channelFor(candidateVersion), 'invalid candidate source, version or channel'); + const git = createGit(repoPath), base = `https://api.github.com/repos/${REPO}`; + const get = (path,allow404) => requestJson(fetchImpl,base+path,token,allow404); + const readNpm = async () => registrySnapshot(await requestJson(fetchImpl,'https://registry.npmjs.org/%40bitkyc08%2Fopencodex',token)); + const [repo,dev,npm,tags,releases] = await Promise.all([get(''),get('/commits/dev'),readNpm(),githubPages(fetchImpl,token,'tags'),githubPages(fetchImpl,token,'releases')]); + must(repo?.id === REPO_ID && repo.full_name === REPO, 'GitHub repository identity mismatch'); + must(SHA.test(dev?.sha || '') && git.isAncestor(candidateSha,dev.sha), 'candidate is not reachable from GitHub dev'); + const gh = githubSnapshot(tags,releases); + const channels = ['preview','stable'].map(c => npm.versions.filter(r => channelFor(r.version) === c).at(-1)).filter(Boolean).sort((a,b) => a.time-b.time); + const cutoff = channels[0].time; + for (const r of npm.versions.filter(r => r.time >= cutoff)) { + const tag = gh.tagMap.get(`v${r.version}`), release = gh.releaseMap.get(`v${r.version}`); + must(SHA.test(r.gitHead || ''), `npm ${r.version} has missing or invalid gitHead; reconcile publication source`); + must(tag, `npm-only publication ${r.version} (${r.gitHead}) has no GitHub tag; authenticated prior receipt and provenance required for reconciliation`); + must(tag === r.gitHead, `GitHub tag v${r.version} does not match npm gitHead`); + must(release && !release.draft && release.prerelease === (channelFor(r.version) === 'preview') && Number.isFinite(Date.parse(release.published_at)), `npm publication ${r.version} lacks a published matching GitHub release; authenticated receipt and provenance required`); + } + for (const r of npm.versions) { + must(compareVersions(candidateVersion,r.version) > 0, `candidate version ${candidateVersion} does not exceed published npm version ${r.version}`); + } + const selectedVersions = new Set(npm.versions.map(r => r.version)); + for (const [tag,release] of gh.releaseMap) { + if (release.draft) continue; + const published = Date.parse(release.published_at); + must(Number.isFinite(published), `GitHub release ${tag} has no publication time`); + if (published >= cutoff && tag.startsWith('v')) must(selectedVersions.has(tag.slice(1)), `GitHub release ${tag} has no matching npm version; reconcile partial publication`); + } + for (const r of channels) { + const legacy = LEGACY_MAPPINGS.find(m => m.version === r.version); + if (legacy) { + must(r.gitHead === legacy.legacySha && gh.tagMap.get(`v${r.version}`) === legacy.legacySha, `legacy ${r.version} registry or tag source does not match the fixed expected SHA`); + verifyLegacyTreeEvidence({repoPath,sourceSha:legacy.legacySha,baselineSha:LEGACY_BASELINE,sourceVersion:legacy.version,baselineVersion:LEGACY_BASELINE_VERSION,candidateSha,devSha:dev.sha}); + } else { + must(git.isAncestor(r.gitHead,candidateSha), `candidate lineage does not include ${r.version} source ${r.gitHead}`); + } + } + const newest = channels.at(-1); + if (candidateSha === newest.gitHead) must(channel === 'stable' && channelFor(newest.version) === 'preview', 'same source only allowed for preview to stable'); + const result = {floor:{version:newest.version,sourceSha:newest.gitHead,publishedAt:newest.publishedAt}}; + const [npmAgain,devAgain,tagsAgain,releasesAgain] = await Promise.all([readNpm(),get('/commits/dev'),githubPages(fetchImpl,token,'tags'),githubPages(fetchImpl,token,'releases')]); + const comparable = snapshot => JSON.stringify({tags:[...snapshot.tagMap].sort(),releases:[...snapshot.releaseMap].sort(([a],[b]) => a.localeCompare(b))}); + must(isDeepStrictEqual(npm,npmAgain) && devAgain?.sha === dev.sha && comparable(gh) === comparable(githubSnapshot(tagsAgain,releasesAgain)), 'publication evidence changed during collection; retry under the shared release lock'); + return {...result,channels:channels.map(({version,gitHead,publishedAt}) => ({version,sourceSha:gitHead,publishedAt})),devSha:dev.sha,versionsChecked:npm.versions.length}; +} + +// Ed25519 signatures authenticate raw bytes. The public key must come from a +// reviewed trusted policy, not an artifact, candidate checkout, or CLI input. +function verifyEd25519Receipt(bytes, signature, trustedPublicKey) { + try { + const key = createPublicKey(trustedPublicKey); + return key.asymmetricKeyType === 'ed25519' && Buffer.isBuffer(bytes) && Buffer.isBuffer(signature) && verify(null, bytes, key, signature); + } + catch { return false; } +} +// Signed JSON has shape {payload,expiresAt}; the verifier rejects a changed +// parsed payload and requires a separate authenticated run/attempt lookup. +function createReceiptVerifier({publicKey, now, verifyRun}) { + must(typeof now === 'function' && typeof verifyRun === 'function', 'receipt verification dependencies required'); + return (envelope, expected) => { + try { + if (!envelope || typeof envelope.signedBytes !== 'string' || typeof envelope.signature !== 'string') return false; + const bytes = Buffer.from(envelope.signedBytes, 'base64'); + const signature = Buffer.from(envelope.signature, 'base64'); + if (!bytes.length || bytes.length > 1024 * 1024 || bytes.toString('base64') !== envelope.signedBytes || signature.toString('base64') !== envelope.signature || + !verifyEd25519Receipt(bytes, signature, publicKey)) return false; + const signed = JSON.parse(bytes.toString('utf8')); + const expires = Date.parse(signed.expiresAt), current = now(); + if (!Number.isFinite(expires) || !Number.isFinite(current) || expires <= current || !isDeepStrictEqual(signed.payload, envelope.payload) || + !sameTuple(signed.payload, expected)) return false; + return verifyRun(signed.payload) === true; + } catch { return false; } + }; +} +// check must run from the trusted workflow revision under the release lock. +// GH_TOKEN is read from the environment; output contains no credentials. +async function main(args) { + const [command, first, second, third, ...extra] = args; + if (command === 'check') { + must(first && second && third && !extra.length, 'usage: release-history.cjs check '); + const result = await collectPublicationHistory({candidateSha:first,candidateVersion:second,channel:third,token:process.env.GH_TOKEN}); + process.stdout.write(`${JSON.stringify(result)}\n`); return; + } + must(command === 'verify-signature' && first && second && third && !extra.length, 'usage: release-history.cjs check | verify-signature '); + must(verifyEd25519Receipt(readFileSync(first), readFileSync(second), readFileSync(third)), 'receipt signature invalid'); + process.stdout.write('signature valid; publication evidence has not been checked\n'); +} +if (require.main === module) main(process.argv.slice(2)).catch(error => { process.stderr.write(`release history: ${error.message}\n`); process.exitCode = 1; }); +module.exports = {compareVersions, reconcileHistory, verifyExactResume, verifyEd25519Receipt, createReceiptVerifier, collectPublicationHistory, verifyLegacyTreeEvidence, LEGACY_MAPPINGS, LEGACY_BASELINE}; diff --git a/.github/scripts/release-history.test.cjs b/.github/scripts/release-history.test.cjs new file mode 100644 index 00000000000..0691262d23d --- /dev/null +++ b/.github/scripts/release-history.test.cjs @@ -0,0 +1,333 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { generateKeyPairSync, sign } = require('node:crypto'); +const { mkdtempSync, rmSync, mkdirSync, writeFileSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join, resolve } = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { reconcileHistory, verifyExactResume, createReceiptVerifier, collectPublicationHistory, verifyLegacyTreeEvidence } = require('./release-history.cjs'); + +const old = 'a'.repeat(40), newer = 'b'.repeat(40), other = 'c'.repeat(40), workflow = 'd'.repeat(40); +const digest = '1'.repeat(64), assetDigest = '2'.repeat(64); +const ancestry = (base, head) => base === head || (base === old && head === newer); +const record = (version, sourceSha, publishedAt) => ({version, gitHead: sourceSha, publishedAt}); +const receipt = (version, sourceSha, channel = 'stable') => ({version, sourceSha, workflowSha: workflow, channel, sha256: digest, runId: 123, attempt: 1, assets: {'app.zip': assetDigest}}); +const authenticated = x => x?.authenticated === true; +const attested = x => x?.verified === true; +const signed = payload => ({payload, authenticated: true}); +const snapshot = overrides => ({ + candidateSha: newer, candidateVersion: '1.0.2', channel: 'stable', + versions: [record('1.0.0', old, '2026-01-01T00:00:00.000Z')], + distTags: {latest: '1.0.0'}, tags: [{version: '1.0.0', sha: old}], + releases: [{version: '1.0.0', tag: 'v1.0.0'}], receipts: [], mappings: [], + isAncestor: ancestry, verifyReceipt: authenticated, ...overrides, +}); + +test('newer npm-only partial publication prevents source rollback', () => { + const r = receipt('1.0.1', newer); + const input = snapshot({candidateSha: old, versions: [record('1.0.0', old, '2026-01-01T00:00:00.000Z'), record('1.0.1', newer, '2026-01-02T00:00:00.000Z')], receipts: [signed(r)]}); + assert.throws(() => reconcileHistory(input), /lineage|rollback/); + assert.throws(() => reconcileHistory({...input, receipts: []}), /receipt/); + assert.throws(() => reconcileHistory({...input, receipts: [{payload:r, authenticated:false}]}), /receipt/); +}); + +test('pure history rejects divergent sources and arbitrary mappings cannot bypass ancestry', () => { + const input = snapshot({versions:[record('1.0.0', other, '2026-01-01T00:00:00.000Z')], tags:[{version:'1.0.0', sha:other}]}); + assert.throws(() => reconcileHistory(input), /lineage/); + assert.throws(() => reconcileHistory({...input, mappings:[{version:'1.0.0', legacySha:other, devSha:old}]}), /lineage/); +}); + +test('pure history rejects divergent channels', () => { + const input = snapshot({versions:[record('1.0.0',other,'2026-01-01T00:00:00.000Z'),record('1.0.1-preview.20260102.1',old,'2026-01-02T00:00:00.000Z')], + tags:[{version:'1.0.0',sha:other},{version:'1.0.1-preview.20260102.1',sha:old}], releases:[{version:'1.0.0',tag:'v1.0.0'},{version:'1.0.1-preview.20260102.1',tag:'v1.0.1-preview.20260102.1'}]}); + assert.throws(() => reconcileHistory(input), /lineage/); +}); + +test('stable may follow preview at the same source but rollback and ambiguity fail', () => { + const input = snapshot({candidateSha:old, candidateVersion:'1.0.1', versions:[record('1.0.1-preview.20260101.1',old,'2026-01-01T00:00:00.000Z')], tags:[{version:'1.0.1-preview.20260101.1',sha:old}], releases:[{version:'1.0.1-preview.20260101.1',tag:'v1.0.1-preview.20260101.1'}], distTags:{preview:'1.0.1-preview.20260101.1'}}); + assert.equal(reconcileHistory(input).floor.sourceSha, old); + assert.throws(() => reconcileHistory({...input, candidateVersion:'1.0.0'}), /version/); + assert.throws(() => reconcileHistory({...input, versions:[...input.versions, record('1.0.0',old,input.versions[0].publishedAt)]}), /order/); +}); + +function resumeInput() { + const tuple = receipt('1.0.1', newer); + return {tuple, receipt:signed(tuple), registry:{version:tuple.version, gitHead:newer, sha256:digest}, provenance:{verified:true}, tag:{name:'v1.0.1',sha:newer}, release:{tag:'v1.0.1',assets:[{name:'app.zip',sha256:assetDigest}]}, historyFloor:{version:tuple.version,sourceSha:newer}, verifyHistory:() => true, verifyReceipt:authenticated, verifyProvenance:attested}; +} +test('verified exact resume never requests republish or overwrites assets', () => { + assert.deepEqual(verifyExactResume(resumeInput()), {publishNpm:false, reuseAssets:['app.zip']}); +}); +test('resume rejects corrupt or forged receipts, registry and provenance mismatches, and asset replacement', () => { + const input = resumeInput(); + assert.throws(() => verifyExactResume({...input, receipt:{...input.receipt,authenticated:false}}), /receipt/); + assert.throws(() => verifyExactResume({...input, receipt:signed({...input.tuple, sha256:'3'.repeat(64)})}), /receipt/); + assert.throws(() => verifyExactResume({...input, registry:{...input.registry,gitHead:old}}), /registry/); + assert.throws(() => verifyExactResume({...input, provenance:{verified:false}}), /provenance/); + assert.throws(() => verifyExactResume({...input, historyFloor:{version:'1.0.2',sourceSha:other}}), /roll back/); + assert.throws(() => verifyExactResume({...input, verifyHistory:() => false}), /history/); + assert.throws(() => verifyExactResume({...input, tag:{name:'v1.0.1',sha:old}}), /tag/); + assert.throws(() => verifyExactResume({...input, release:{tag:'v1.0.1',assets:[{name:'app.zip',sha256:digest}]}}), /asset/); + assert.throws(() => verifyExactResume({...input, release:{tag:'v1.0.1',assets:[{name:'extra.zip',sha256:digest}]}}), /asset/); +}); + +test('real signed receipt binds bytes, tuple, expiry, and authenticated run attempt', () => { + const {privateKey, publicKey} = generateKeyPairSync('ed25519'); + const publicPem = publicKey.export({type:'spki',format:'pem'}); + const input = resumeInput(); + const bytes = Buffer.from(JSON.stringify({payload:input.tuple,expiresAt:'2026-01-02T00:00:00.000Z'})); + const envelope = {payload:input.tuple, signedBytes:bytes.toString('base64'), signature:sign(null,bytes,privateKey).toString('base64')}; + const makeVerifier = (now, verifyRun = t => t.runId === 123 && t.attempt === 1) => createReceiptVerifier({publicKey:publicPem, now:() => Date.parse(now), verifyRun}); + const verifyReceipt = makeVerifier('2026-01-01T00:00:00.000Z'); + assert.deepEqual(verifyExactResume({...input,receipt:envelope,verifyReceipt}), {publishNpm:false,reuseAssets:['app.zip']}); + assert.throws(() => verifyExactResume({...input,receipt:{...envelope,payload:{...input.tuple,attempt:2}},verifyReceipt}), /receipt/); + const forged = Buffer.from(bytes); forged[10] ^= 1; + assert.throws(() => verifyExactResume({...input,receipt:{...envelope,signedBytes:forged.toString('base64')},verifyReceipt}), /receipt/); + assert.throws(() => verifyExactResume({...input,receipt:envelope,verifyReceipt:makeVerifier('2026-01-02T00:00:00.000Z')}), /receipt/); + assert.throws(() => verifyExactResume({...input,receipt:envelope,verifyReceipt:makeVerifier('2026-01-01T00:00:00.000Z', () => false)}), /receipt/); + const history = snapshot({versions:[record('1.0.1',newer,'2026-01-01T00:00:00.000Z')], distTags:{latest:'1.0.1'}, tags:[], releases:[], receipts:[envelope], candidateSha:old, candidateVersion:'1.0.2', verifyReceipt}); + assert.throws(() => reconcileHistory(history), /lineage|rollback/); + assert.throws(() => reconcileHistory({...history,receipts:[{...envelope, signature:Buffer.alloc(64).toString('base64')}]}), /receipt/); + const resumed = reconcileHistory({...history,candidateSha:newer,candidateVersion:'1.0.1',distTags:{latest:'1.0.1'},resume:true}); + assert.equal(resumed.floor.sourceSha, newer); + assert.throws(() => reconcileHistory({...history,candidateSha:old,candidateVersion:'1.0.0',distTags:{latest:'1.0.1'},resume:true}), /roll back/); +}); + +test('history rejects missing sources, unknown tags, and missing registry evidence', () => { + const input = snapshot(); + assert.throws(() => reconcileHistory({...input, versions:[record('1.0.0','', '2026-01-01T00:00:00.000Z')]}), /source/); + assert.throws(() => reconcileHistory({...input, distTags:{latest:'1.0.5'}}), /dist-tag/); + assert.throws(() => reconcileHistory({...input, tags:[{version:'1.0.1',sha:old}]}), /tag/); + assert.throws(() => reconcileHistory({...input, releases:[{version:'1.0.0',tag:'v1.0.1'}]}), /release/); +}); + +function collectorFixture(t) { + const dir = mkdtempSync(join(tmpdir(), 'ocx-release-history-')); + t.after(() => rmSync(dir, {recursive:true, force:true})); + const git = (...args) => execFileSync('git', ['-C',dir,...args], {encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim(); + git('init','-q'); + const commit = name => { git('-c','user.name=Test','-c','user.email=test@localhost','commit','--allow-empty','-qm',name); return git('rev-parse','HEAD'); }; + const first = commit('preview'), second = commit('stable'), candidate = commit('candidate'); + const control = {devSha:candidate}; + const data = {name:'@bitkyc08/opencodex',versions:{},time:{},'dist-tags':{preview:'1.0.1-preview.20260101.1',latest:'1.0.1'}}; + const refs = new Map(); + function publish(v, sha, time, hasTag = true) { + data.versions[v] = {name:data.name,version:v,gitHead:sha}; data.time[v] = time; + if (hasTag) refs.set(v,sha); + } + publish('1.0.1-preview.20260101.1',first,'2026-01-01T00:00:00.000Z'); + publish('1.0.1',second,'2026-01-02T00:00:00.000Z'); + const fetchImpl = async (url, options) => { + const u = new URL(url); + assert.ok(options.signal); assert.equal(options.redirect,'error'); + if (u.hostname === 'registry.npmjs.org') return Response.json(data); + assert.equal(u.hostname,'api.github.com'); assert.equal(options.headers.Authorization,'Bearer test-token'); + const path = u.pathname.replace('/repos/lidge-jun/opencodex',''); + if (path === '') return Response.json({id:1273824907,full_name:'lidge-jun/opencodex'}); + if (path === '/commits/dev') return Response.json({sha:control.devSha}); + if (path === '/tags') return Response.json([...refs].map(([v,sha]) => ({name:`v${v}`,commit:{sha}}))); + if (path === '/releases') return Response.json([...refs].map(([v]) => ({tag_name:`v${v}`,draft:false,prerelease:v.includes('-preview.'),published_at:data.time[v]}))); + const m = /^\/git\/ref\/tags\/v(.+)$/.exec(path); + if (m) return refs.has(m[1]) ? Response.json({ref:`refs/tags/v${m[1]}`,object:{type:'commit',sha:refs.get(m[1])}}) : new Response('',{status:404}); + const r = /^\/releases\/tags\/v(.+)$/.exec(path); + if (r) return refs.has(r[1]) ? Response.json({tag_name:`v${r[1]}`,draft:false,prerelease:r[1].includes('-preview.'),published_at:'2026-01-01T00:00:00Z'}) : new Response('',{status:404}); + throw Error(`unexpected mock URL ${url}`); + }; + return {dir, git, commit, control, first, second, candidate, data, refs, publish, fetchImpl, args:{candidateSha:candidate,candidateVersion:'1.0.2',channel:'stable',repoPath:dir,token:'test-token',fetchImpl,mappings:[]}}; +} + +test('collector uses live-shaped npm and authenticated GitHub responses with real Git ancestry', async t => { + const f = collectorFixture(t); + const result = await collectPublicationHistory(f.args); + assert.deepEqual(result.floor,{version:'1.0.1',sourceSha:f.second,publishedAt:'2026-01-02T00:00:00.000Z'}); + assert.deepEqual(result.channels.map(x => x.version),['1.0.1-preview.20260101.1','1.0.1']); +}); + +test('collector refuses a newer npm-only source and missing source metadata', async t => { + const f = collectorFixture(t); + f.publish('1.0.2-preview.20260103.1',f.candidate,'2026-01-03T00:00:00.000Z',false); + await assert.rejects(collectPublicationHistory({...f.args,candidateSha:f.second,candidateVersion:'1.0.3'}), /npm-only|rollback|lineage/i); + await assert.rejects(collectPublicationHistory({...f.args,candidateVersion:'1.0.3'}), /npm-only.*receipt/i); + f.data.versions['1.0.2-preview.20260103.1'].gitHead = ''; + await assert.rejects(collectPublicationHistory({...f.args,candidateVersion:'1.0.3'}), /gitHead/); +}); + +test('collector refuses registry races and mismatched GitHub tag source', async t => { + const f = collectorFixture(t); + f.refs.set('1.0.1',f.first); + await assert.rejects(collectPublicationHistory(f.args), /tag.*gitHead/i); + f.refs.set('1.0.1',f.second); + let reads = 0; + const racingFetch = async (url, options) => { + if (new URL(url).hostname === 'registry.npmjs.org' && ++reads === 2) f.data['dist-tags'].next = '1.0.1'; + return f.fetchImpl(url,options); + }; + await assert.rejects(collectPublicationHistory({...f.args,fetchImpl:racingFetch}), /changed|race/i); +}); + +test('collector rejects arbitrary mappings for divergent nonlegacy channel histories', async t => { + const f = collectorFixture(t); + f.git('checkout','-q','--detach',f.first); + const divergent = f.commit('divergent preview'); + f.git('checkout','-q','--detach',f.candidate); + const preview = '1.0.1-preview.20260101.1'; + f.data.versions[preview].gitHead = divergent; f.refs.set(preview,divergent); + await assert.rejects(collectPublicationHistory({...f.args,mappings:[{version:preview,legacySha:divergent,devSha:f.candidate}]}), /lineage/); +}); + +test('collector rejects missing releases, ambiguous timestamps, and missing commit objects', async t => { + const f = collectorFixture(t); + const missingRelease = async (url, options) => { + const res = await f.fetchImpl(url,options); + if (new URL(url).pathname.endsWith('/releases')) { + const values = await res.json(); return Response.json(values.filter(v => v.tag_name !== 'v1.0.1')); + } + return res; + }; + await assert.rejects(collectPublicationHistory({...f.args,fetchImpl:missingRelease}), /matching GitHub release/); + f.data.time['1.0.1'] = f.data.time['1.0.1-preview.20260101.1']; + await assert.rejects(collectPublicationHistory(f.args), /ambiguous.*order/); + f.data.time['1.0.1'] = '2026-01-02T00:00:00.000Z'; + f.data.versions['1.0.1'].gitHead = 'f'.repeat(40); f.refs.set('1.0.1','f'.repeat(40)); + await assert.rejects(collectPublicationHistory(f.args), /Git source.*unavailable/); +}); + +test('collector accepts a strictly superseded historical tag mismatch', async t => { + const f = collectorFixture(t); + f.publish('1.0.0',f.first,'2025-12-30T00:00:00.000Z'); + f.refs.set('1.0.0',f.second); + assert.equal((await collectPublicationHistory(f.args)).floor.sourceSha,f.second); + f.refs.delete('1.0.0'); delete f.data.versions['1.0.0'].gitHead; + assert.equal((await collectPublicationHistory(f.args)).floor.sourceSha,f.second); +}); + +test('collector still enforces the version floor for superseded publications', async t => { + const f = collectorFixture(t); + f.publish('1.9.0',f.first,'2025-12-30T00:00:00.000Z'); + await assert.rejects(collectPublicationHistory(f.args), /candidate version.*1\.9\.0/); +}); + +test('collector rejects a mismatch at the older channel floor', async t => { + const f = collectorFixture(t); + f.refs.set('1.0.1-preview.20260101.1',f.second); + await assert.rejects(collectPublicationHistory(f.args), /tag.*gitHead/i); +}); + +test('collector rejects an npm-only publication newer than the older channel floor', async t => { + const f = collectorFixture(t); + f.publish('1.0.0',f.first,'2026-01-01T12:00:00.000Z',false); + await assert.rejects(collectPublicationHistory(f.args), /npm-only.*receipt/i); +}); + +test('collector refuses an arbitrary legacy mapping even with a future baseline', async t => { + const f = collectorFixture(t); + f.git('checkout','-q','--detach',f.first); + const divergent = f.commit('divergent preview'); + f.git('checkout','-q','--detach',f.candidate); + const future = f.commit('future baseline'); + const preview = '1.0.1-preview.20260101.1'; + f.data.versions[preview].gitHead = divergent; f.refs.set(preview,divergent); + const mappings = [{version:preview,legacySha:divergent,devSha:future},{version:'1.0.1',legacySha:f.second,devSha:future}]; + await assert.rejects(collectPublicationHistory({...f.args,mappings}), /lineage|baseline|ancestor/i); +}); + +test('collector accepts a candidate that merges both independently verified channel sources', async t => { + const f = collectorFixture(t); + f.git('checkout','-q','--detach',f.first); + const divergent = f.commit('divergent preview'); + f.git('checkout','-q','--detach',f.candidate); + f.git('-c','user.name=Test','-c','user.email=test@localhost','merge','--no-ff','-qm','merge preview',divergent); + const merged = f.git('rev-parse','HEAD'); + f.control.devSha = merged; + const preview = '1.0.1-preview.20260101.1'; + f.data.versions[preview].gitHead = divergent; f.refs.set(preview,divergent); + const result = await collectPublicationHistory({...f.args,candidateSha:merged}); + assert.equal(result.floor.sourceSha,f.second); +}); + +test('collector refuses unknown publication order even for old versions', async t => { + const f = collectorFixture(t); + f.publish('1.0.0',f.first,'2025-12-30T00:00:00.000Z'); + delete f.data.time['1.0.0']; + await assert.rejects(collectPublicationHistory(f.args), /publication time/); +}); + +test('collector reads all GitHub tag pages on both snapshots', async t => { + const f = collectorFixture(t); + let pageTwo = 0; + const paginatedFetch = async (url, options) => { + const u = new URL(url); + if (u.hostname === 'api.github.com' && u.pathname.endsWith('/tags')) { + const real = [...f.refs].map(([v,sha]) => ({name:`v${v}`,commit:{sha}})); + const filler = Array.from({length:99},(_,i) => ({name:`archive-${i}`,commit:{sha:f.first}})); + const combined = [...real,...filler]; + const page = Number(u.searchParams.get('page')); + if (page === 2) pageTwo++; + return Response.json(combined.slice((page-1)*100,page*100)); + } + return f.fetchImpl(url,options); + }; + await collectPublicationHistory({...f.args,fetchImpl:paginatedFetch}); + assert.equal(pageTwo,2); +}); + +test('collector permits preview to stable on the identical source', async t => { + const f = collectorFixture(t); + delete f.data.versions['1.0.1']; delete f.data.time['1.0.1']; delete f.data['dist-tags'].latest; f.refs.delete('1.0.1'); + const result = await collectPublicationHistory({...f.args,candidateSha:f.first,candidateVersion:'1.0.1'}); + assert.equal(result.floor.sourceSha,f.first); +}); + +test('collector rejects GitHub API failures and requires authentication', async t => { + const f = collectorFixture(t); + await assert.rejects(collectPublicationHistory({...f.args,token:''}), /GH_TOKEN/); + const fetchImpl = async (url, options) => new URL(url).pathname.endsWith('/tags') ? new Response('',{status:403}) : f.fetchImpl(url,options); + await assert.rejects(collectPublicationHistory({...f.args,fetchImpl}), /GitHub evidence request failed \(403\)/); +}); + +test('fixed stable and preview legacy trees differ from dev baseline only by verified version values', () => { + const baseline = '0627a2c255bc239d5748119f1d0bc4457a1c03e3'; + for (const [sourceSha,sourceVersion] of [['667662903a6c139524506667d12656d9ad50fad0','2.99.0'],['68eee1eb804445ad86fe942b734060f323eb1517','2.99.0-preview.20261108']]) { + assert.equal(verifyLegacyTreeEvidence({repoPath:resolve(__dirname,'../..'),sourceSha,baselineSha:baseline,sourceVersion,baselineVersion:'2.100.0',candidateSha:baseline,devSha:baseline}),true); + } +}); + +test('collector verifies both fixed legacy sources and refuses a forged legacy SHA', async t => { + const f = collectorFixture(t); + const baseline = '0627a2c255bc239d5748119f1d0bc4457a1c03e3'; + const stable = '667662903a6c139524506667d12656d9ad50fad0', preview = '68eee1eb804445ad86fe942b734060f323eb1517'; + f.data.versions = {}; f.data.time = {}; f.refs.clear(); f.control.devSha = baseline; + f.publish('2.99.0-preview.20261108',preview,'2026-01-01T00:00:00.000Z'); + f.publish('2.99.0',stable,'2026-01-02T00:00:00.000Z'); + f.data['dist-tags'] = {preview:'2.99.0-preview.20261108',latest:'2.99.0'}; + const args = {...f.args,repoPath:resolve(__dirname,'../..'),candidateSha:baseline,candidateVersion:'2.100.0'}; + assert.equal((await collectPublicationHistory(args)).floor.sourceSha,stable); + f.data.versions['2.99.0'].gitHead = baseline; f.refs.set('2.99.0',baseline); + await assert.rejects(collectPublicationHistory(args), /fixed expected SHA/); +}); + +test('legacy tree evidence rejects other changes, wrong version values, and an uncontained baseline', t => { + const dir = mkdtempSync(join(tmpdir(),'ocx-legacy-evidence-')); + t.after(() => rmSync(dir,{recursive:true,force:true})); + const git = (...args) => execFileSync('git',['-C',dir,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim(); + git('init','-q'); mkdirSync(join(dir,'desktop/src-tauri'),{recursive:true}); + const write = (version,extra = '') => { + writeFileSync(join(dir,'package.json'),JSON.stringify({name:'fixture',version})+'\n'); + writeFileSync(join(dir,'desktop/src-tauri/tauri.conf.json'),JSON.stringify({version})+'\n'); + writeFileSync(join(dir,'desktop/src-tauri/Cargo.toml'),`[package]\nname = "opencodex-desktop"\nversion = "${version}"\n${extra}`); + writeFileSync(join(dir,'desktop/src-tauri/Cargo.lock'),`[[package]]\nname = "opencodex-desktop"\nversion = "${version}"\n`); + }; + const commit = msg => {git('add','.');git('-c','user.name=Test','-c','user.email=test@localhost','commit','-qm',msg);return git('rev-parse','HEAD');}; + write('1.0.0'); const sourceSha = commit('source'); + write('1.1.0'); const baselineSha = commit('baseline'); + const args = {repoPath:dir,sourceSha,baselineSha,sourceVersion:'1.0.0',baselineVersion:'1.1.0',candidateSha:baselineSha,devSha:baselineSha}; + assert.equal(verifyLegacyTreeEvidence(args),true); + assert.throws(() => verifyLegacyTreeEvidence({...args,sourceVersion:'1.0.1'}), /version/); + assert.throws(() => verifyLegacyTreeEvidence({...args,candidateSha:sourceSha}), /baseline.*candidate/i); + assert.throws(() => verifyLegacyTreeEvidence({...args,devSha:sourceSha}), /baseline.*dev/i); + write('1.1.0','description = "unexpected"\n'); const changedFile = commit('other content'); + assert.throws(() => verifyLegacyTreeEvidence({...args,baselineSha:changedFile,candidateSha:changedFile,devSha:changedFile}), /version|content/i); + writeFileSync(join(dir,'extra.txt'),'unexpected\n'); const extraPath = commit('extra path'); + assert.throws(() => verifyLegacyTreeEvidence({...args,baselineSha:extraPath,candidateSha:extraPath,devSha:extraPath}), /paths|diff/i); +}); diff --git a/.github/scripts/release-resume.cjs b/.github/scripts/release-resume.cjs new file mode 100755 index 00000000000..0106bdbf193 --- /dev/null +++ b/.github/scripts/release-resume.cjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +'use strict'; + +// The operator attests that this version was published from this source. +// Registry metadata and GitHub state are consistency checks, not provenance. +// Call from trusted workflow code with the release lock held; repeat immediately +// before release creation and asset attachment. This helper never publishes npm. +// Asset receipts must come from the trusted verify-release job in this workflow; +// this helper does not authenticate an arbitrary receipt file. +const { lstatSync, readFileSync } = require('node:fs'); +const { isDeepStrictEqual } = require('node:util'); +const { compareVersions } = require('./release-history.cjs'); +const REPO = 'lidge-jun/opencodex'; +const PACKAGE = '@bitkyc08/opencodex'; +const API = `https://api.github.com/repos/${REPO}`; +const REGISTRY = 'https://registry.npmjs.org/%40bitkyc08%2Fopencodex'; +const SHA = /^[a-f0-9]{40}$/, DIGEST = /^[a-f0-9]{64}$/; +const VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-preview\.\d{8}\.\d+)?$/; +function must(condition, message) { if (!condition) throw new Error(message); } +function timestamp(value) { + const time = Date.parse(value); + must(typeof value === 'string' && Number.isFinite(time) && new Date(time).toISOString() === value, 'invalid npm publication time'); + return time; +} +async function requestJson(fetchImpl,url,token,allow404 = false) { + const github = url.startsWith(API + '/'); + must(github || url === REGISTRY, 'unexpected evidence endpoint'); + let response; + try { + response = await fetchImpl(url,{headers:github ? {Authorization:`Bearer ${token}`,Accept:'application/vnd.github+json','X-GitHub-Api-Version':'2022-11-28'} : {Accept:'application/json'},redirect:'error',cache:'no-store',signal:AbortSignal.timeout(30000)}); + } catch { throw new Error('evidence request failed'); } + if (allow404 && response.status === 404) return null; + must(response.ok, `evidence request failed (${response.status})`); + const length = response.headers.get('content-length'); + must(!length || (/^\d+$/.test(length) && Number(length) <= 20*1024*1024), 'evidence too large'); + let size = 0; const chunks = []; + must(response.body, 'empty evidence response'); + for await (const chunk of response.body) { size += chunk.length; must(size <= 20*1024*1024, 'evidence too large'); chunks.push(chunk); } + try { return JSON.parse(Buffer.concat(chunks,size).toString('utf8')); } + catch { throw new Error('invalid JSON evidence'); } +} +async function pages(fetchImpl,token,path) { + const result=[]; + for (let page=1;page<=100;page++) { + const list=await requestJson(fetchImpl,`${API}/${path}?per_page=100&page=${page}`,token); + must(Array.isArray(list) && list.length<=100, 'invalid GitHub pagination'); + result.push(...list); + if (list.length<100) return result; + } + throw new Error('GitHub pagination limit exceeded'); +} +function registryCheck(data,version,sourceSha,now) { + must(data?.name===PACKAGE && data.versions && typeof data.versions==='object' && !Array.isArray(data.versions) && data.time && typeof data.time==='object' && data['dist-tags'] && typeof data['dist-tags']==='object' && !Array.isArray(data['dist-tags']), 'invalid npm history'); + const entries=Object.entries(data.versions); + must(entries.length>0 && entries.length<=10000 && Object.hasOwn(data.versions,version), 'npm version is missing'); + const published=timestamp(data.time[version]); must(published<=now,'future npm publication'); + for (const [v,metadata] of entries) { + must(metadata?.name===PACKAGE && metadata.version===v, 'invalid npm version identity'); + const time=timestamp(data.time[v]); + must(time<=now && (v===version || time0, 'empty verified asset receipt'); + for (const name of names) must(name && name!=='.' && name!=='..' && !/[\x00-\x1f\x7f/\\]/.test(name) && DIGEST.test(expected[name]), 'invalid verified asset receipt'); + for (const asset of assets) { + must(asset && typeof asset.name==='string' && !seen.has(asset.name) && Object.hasOwn(expected,asset.name) && asset.state==='uploaded' && asset.digest===`sha256:${expected[asset.name]}`, 'existing release asset mismatch'); + seen.add(asset.name); + } + const missing=names.filter(name=>!seen.has(name)).sort(); + must(!release || release.draft || missing.length===0, 'published release missing verified assets'); + return {reuseAssets:[...seen].sort(),missingAssets:missing}; +} +async function collectResumeDecision({version,sourceSha,channel,token,fetchImpl=fetch,now=Date.now,assetReceipt}) { + must(typeof version==='string' && VERSION.test(version) && SHA.test(sourceSha || '') && channel===(version.includes('-')?'preview':'stable'), 'explicit version, source and matching channel are required'); + must(typeof token==='string' && token.length>0 && typeof fetchImpl==='function', 'GH_TOKEN is required'); + const clock=now(); must(Number.isFinite(clock), 'invalid clock'); + const read=async()=> { + const [registry,tags,release]=await Promise.all([ + requestJson(fetchImpl,REGISTRY,token),pages(fetchImpl,token,'tags'), + requestJson(fetchImpl,`${API}/releases/tags/v${version}`,token,true), + ]); + registryCheck(registry,version,sourceSha,clock); + const tagExists=tagCheck(tags,version,sourceSha); + if (release!==null) must(Number.isSafeInteger(release.id) && release.id>0 && release.tag_name===`v${version}` && typeof release.draft==='boolean' && release.prerelease===(channel==='preview'), 'invalid GitHub release evidence'); + const assets=assetReceipt!==undefined && release ? await pages(fetchImpl,token,`releases/${release.id}/assets`) : []; + return {registry,tags,release,assets,tagExists}; + }; + const first=await read(), second=await read(); + must(isDeepStrictEqual(first,second), 'publication evidence changed while checking'); + const result={version,sourceSha,channel,publishNpm:false,tagExists:first.tagExists,releaseExists:first.release!==null,assetsChecked:assetReceipt!==undefined}; + if (assetReceipt!==undefined) Object.assign(result,checkAssets(assetReceipt,first.release,first.assets,version,sourceSha)); + return result; +} +async function main() { + const [command,version,sourceSha,channel,receiptPath,...extra]=process.argv.slice(2); + must((command==='check' && !receiptPath && !extra.length) || (command==='check-assets' && receiptPath && !extra.length), 'usage: release-resume.cjs check | check-assets '); + let assetReceipt; + if (command==='check-assets') { + const stat=lstatSync(receiptPath); must(stat.isFile() && !stat.isSymbolicLink() && stat.size<=1024*1024, 'invalid verified receipt file'); + assetReceipt=JSON.parse(readFileSync(receiptPath,'utf8')); + } + const result=await collectResumeDecision({version,sourceSha,channel,token:process.env.GH_TOKEN,assetReceipt}); + process.stdout.write(JSON.stringify(result)+'\n'); +} +if (require.main===module) main().catch(error=>{console.error(error.message);process.exitCode=1;}); +module.exports={collectResumeDecision}; diff --git a/.github/scripts/release-resume.test.cjs b/.github/scripts/release-resume.test.cjs new file mode 100644 index 00000000000..0ad20d2ff48 --- /dev/null +++ b/.github/scripts/release-resume.test.cjs @@ -0,0 +1,112 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mkdtempSync, writeFileSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { collectResumeDecision } = require('./release-resume.cjs'); +const packageName = '@bitkyc08/opencodex', sourceSha = 'a'.repeat(40), version = '2.100.1'; +const assetDigest = 'b'.repeat(64); +function fixture() { + const data = { + registry:{name:packageName,versions:{[version]:{name:packageName,version,gitHead:sourceSha}},time:{[version]:'2026-01-01T00:00:00.000Z'},'dist-tags':{latest:version}}, + tags:[{name:`v${version}`,commit:{sha:sourceSha}}], + release:null, + receipt:{version,sha:sourceSha,repo:'lidge-jun/opencodex',assets:{'ocx.zip':assetDigest}}, + }; + const fetchImpl = async (url,options) => { + assert.equal(options.method,undefined); + if (url === 'https://registry.npmjs.org/%40bitkyc08%2Fopencodex') { assert.equal(options.headers.Authorization,undefined); return Response.json(data.registry); } + assert.equal(options.headers.Authorization,'Bearer token'); + if (url.endsWith('/tags?per_page=100&page=1')) return Response.json(data.tags); + if (url.endsWith(`/releases/tags/v${version}`)) return data.release === null ? new Response('',{status:404}) : Response.json(data.release); + if (url.endsWith('/releases/99/assets?per_page=100&page=1')) return Response.json(data.release.assets); + throw Error(`unexpected URL ${url}`); + }; + return {data,args:{version,sourceSha,channel:'stable',token:'token',fetchImpl,now:()=>Date.parse('2026-01-02T00:00:00Z')}}; +} +test('explicit attested version and source produce a resume decision without npm publication', async () => { + const f=fixture(); + assert.deepEqual(await collectResumeDecision(f.args),{version,sourceSha,channel:'stable',publishNpm:false,tagExists:true,releaseExists:false,assetsChecked:false}); +}); +test('missing tag is allowed but wrong or unknown metadata is rejected', async () => { + const f=fixture(); f.data.tags=[]; + assert.equal((await collectResumeDecision(f.args)).tagExists,false); + for (const change of [ + f=>f.data.registry.versions[version].gitHead='c'.repeat(40), + f=>delete f.data.registry.versions[version], + f=>f.data.registry.versions[version].version='2.100.0', + f=>f.data.tags[0].commit.sha='c'.repeat(40), + f=>f.data.tags[0].commit.sha=null, + f=>delete f.data.registry.time[version], + f=>f.args.channel='preview', + f=>f.args.sourceSha='', + ]) { const x=fixture(); change(x); await assert.rejects(collectResumeDecision(x.args)); } +}); +test('newer npm publication or git tag blocks rollback', async () => { + for (const change of [ + f=>{f.data.registry.versions['2.100.2']={name:packageName,version:'2.100.2',gitHead:'c'.repeat(40)}; f.data.registry.time['2.100.2']='2026-01-01T01:00:00.000Z';}, + f=>{f.data.registry.versions['2.100.0']={name:packageName,version:'2.100.0',gitHead:'c'.repeat(40)}; f.data.registry.time['2.100.0']='2026-01-01T01:00:00.000Z';}, + f=>{f.data.tags.push({name:'v2.100.2',commit:{sha:sourceSha}});}, + ]) { const f=fixture(); change(f); await assert.rejects(collectResumeDecision(f.args),/floor|rollback/i); } +}); +test('existing release assets can only be reused against a matching verified receipt', async () => { + const f=fixture(); + f.data.release={id:99,tag_name:`v${version}`,draft:true,prerelease:false,assets:[{name:'ocx.zip',state:'uploaded',digest:`sha256:${assetDigest}`}]}; + const decision=await collectResumeDecision({...f.args,assetReceipt:f.data.receipt}); + assert.equal(decision.publishNpm,false); assert.deepEqual(decision.reuseAssets,['ocx.zip']); assert.deepEqual(decision.missingAssets,[]); + f.data.release.assets[0].digest='sha256:'+'c'.repeat(64); + await assert.rejects(collectResumeDecision({...f.args,assetReceipt:f.data.receipt}),/asset/i); + f.data.release.assets[0].digest=`sha256:${assetDigest}`; + await assert.rejects(collectResumeDecision({...f.args,assetReceipt:{...f.data.receipt,sha:'c'.repeat(40)}}),/receipt/i); +}); +test('draft may upload missing verified assets; published release cannot be missing them', async () => { + const f=fixture(); f.data.release={id:99,tag_name:`v${version}`,draft:true,prerelease:false,assets:[]}; + const result=await collectResumeDecision({...f.args,assetReceipt:f.data.receipt}); + assert.deepEqual(result.missingAssets,['ocx.zip']); + f.data.release.draft=false; + await assert.rejects(collectResumeDecision({...f.args,assetReceipt:f.data.receipt}),/missing/i); +}); +test('unknown and duplicate release assets and changing registry evidence are rejected', async () => { + const f=fixture(); + const asset={name:'ocx.zip',state:'uploaded',digest:`sha256:${assetDigest}`}; + f.data.release={id:99,tag_name:`v${version}`,draft:true,prerelease:false,assets:[asset,asset]}; + await assert.rejects(collectResumeDecision({...f.args,assetReceipt:f.data.receipt}),/asset/i); + f.data.release.assets=[{...asset,name:'unknown.zip'}]; + await assert.rejects(collectResumeDecision({...f.args,assetReceipt:f.data.receipt}),/asset/i); + const g=fixture(); let reads=0; const original=g.args.fetchImpl; + g.args.fetchImpl=async (...args)=>{ + if (args[0].startsWith('https://registry.npmjs.org/') && ++reads===2) g.data.registry['dist-tags'].preview=version; + return original(...args); + }; + await assert.rejects(collectResumeDecision(g.args),/changed/i); +}); +test('a newer tag on a later GitHub API page blocks resume', async () => { + const f=fixture(), original=f.args.fetchImpl; + f.args.fetchImpl=async (url,options)=>{ + if (url.endsWith('/tags?per_page=100&page=1')) return Response.json(Array.from({length:100},(_,i)=>({name:`old-${i}`,commit:{sha:sourceSha}}))); + if (url.endsWith('/tags?per_page=100&page=2')) return Response.json([{name:'v2.100.2',commit:{sha:sourceSha}}]); + return original(url,options); + }; + await assert.rejects(collectResumeDecision(f.args),/tag floor/); +}); +test('CLI consumes explicit version and source and prints the no-publish decision', () => { + const f=fixture(), dir=mkdtempSync(join(tmpdir(),'resume-cli-')); + try { + const fixturePath=join(dir,'fixture.json'), preload=join(dir,'preload.cjs'); + writeFileSync(fixturePath,JSON.stringify(f.data)); + writeFileSync(preload,`const d=require(${JSON.stringify(fixturePath)}); global.fetch=async (u,o)=>{if(o.method) throw Error('unexpected method'); if(u.startsWith('https://registry.npmjs.org/')) return Response.json(d.registry); if(u.includes('/tags?')) return Response.json(d.tags); if(u.includes('/releases/tags/')) return d.release ? Response.json(d.release) : new Response('',{status:404}); if(u.includes('/releases/99/assets?')) return Response.json(d.release.assets); throw Error('unexpected request');};`); + const run=spawnSync(process.execPath,['--require',preload,join(__dirname,'release-resume.cjs'),'check',version,sourceSha,'stable'],{encoding:'utf8',env:{...process.env,GH_TOKEN:'token'}}); + assert.equal(run.status,0,run.stderr); + assert.deepEqual(JSON.parse(run.stdout),{version,sourceSha,channel:'stable',publishNpm:false,tagExists:true,releaseExists:false,assetsChecked:false}); + f.data.release={id:99,tag_name:`v${version}`,draft:true,prerelease:false,assets:[{name:'ocx.zip',state:'uploaded',digest:`sha256:${assetDigest}`}]}; + writeFileSync(fixturePath,JSON.stringify(f.data)); + const receiptPath=join(dir,'receipt.json'); writeFileSync(receiptPath,JSON.stringify(f.data.receipt)); + const assets=spawnSync(process.execPath,['--require',preload,join(__dirname,'release-resume.cjs'),'check-assets',version,sourceSha,'stable',receiptPath],{encoding:'utf8',env:{...process.env,GH_TOKEN:'token'}}); + assert.equal(assets.status,0,assets.stderr); + assert.deepEqual(JSON.parse(assets.stdout).reuseAssets,['ocx.zip']); + const invalid=spawnSync(process.execPath,['--require',preload,join(__dirname,'release-resume.cjs'),'check',version,'','stable'],{encoding:'utf8',env:{...process.env,GH_TOKEN:'token'}}); + assert.equal(invalid.status,1); assert.equal(invalid.stdout,''); + } finally {rmSync(dir,{recursive:true,force:true});} +}); diff --git a/.github/workflows/catalog-async-contracts.yml b/.github/workflows/catalog-async-contracts.yml index eb6f0335009..b35e4286cbd 100644 --- a/.github/workflows/catalog-async-contracts.yml +++ b/.github/workflows/catalog-async-contracts.yml @@ -1,15 +1,6 @@ name: Catalog async contracts on: - push: - branches: [main, preview] - paths: - - src/** - - scripts/diagnostics/windows-version-control/** - - .github/actions/setup-project-bun/** - - .github/workflows/catalog-async-contracts.yml - - package.json - - bun.lock pull_request: paths: - src/** @@ -28,15 +19,30 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + validate-source: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Validate dispatch source + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + run: | + if [ "$EVENT_NAME" = workflow_dispatch ] && [ "$REF" != refs/heads/dev ]; then + echo '::error::manual catalog contracts must run from dev' + exit 1 + fi + contracts: + needs: validate-source name: Catalog async contracts (${{ matrix.os }}) runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: fail-fast: false matrix: - # Pull requests run the ubuntu leg only; macos and windows run on push - # to main/preview and on manual dispatch. + # Pull requests run the ubuntu leg only; macos and windows run on manual dispatch. os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e43e5a60cb..dc5496ad7e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,12 @@ # CI POLICY (maintainer decision — do not change in a contributor PR) # * Pull requests run ONLY the fast Ubuntu subset (default: gates + `test changed`). # * Full cross-platform (macOS/Windows) CI runs ONLY via manual -# workflow_dispatch and on push to main/preview (release lines). -# * NEVER add a dev push trigger or pull_request runs for macOS/Windows jobs. +# workflow_dispatch on dev. +# * NEVER add a push trigger or pull_request runs for macOS/Windows jobs. # PRs that change this policy will be closed. See AGENTS.md "CI policy". # ============================================================================ name: Cross-platform CI +run-name: ocx-ci-v1 lane=${{ github.event.inputs.lane || 'all' }} sha=${{ github.sha }} on: # Always create the aggregate `ci` check for pull requests. Expensive jobs @@ -14,7 +15,7 @@ on: # docs-only PR receives explicit positive evidence instead of no check at all. pull_request: {} # No base-branch filter on purpose. GitHub matches `branches:` against the - # BASE ref, so `[main, dev]` silently excluded stacked child PRs — whose + # BASE ref, so even `[dev]` would silently exclude stacked child PRs — whose # base is another open PR's head branch, an intentional review workflow per # AGENTS.md that `enforce-target` already exempts from the wrong-base gate. # The #951-#955 stack merged with `enforce-target`, `label`, and @@ -24,76 +25,25 @@ on: # An allowlist cannot express "base is another PR's head" — stacked bases # carry contributor prefixes (`fix/`, `feat/`, `agent/`) as readily as # `codex/`, and contributor stacks need CI most. The `changes` job below is - # the real scope gate, using the same allowlist as the push trigger. Safe to + # the real scope gate for expensive jobs. Safe to # widen here because this workflow is `pull_request` (not `pull_request_target`), # declares `contents: read`, and reads no secrets. # - # Pull requests run only the fast Ubuntu subset. Every macOS and Windows - # job (and the macOS/Windows legs of the keyring, npm-global, setup-action - # and remote-helper matrices) is skipped on `pull_request` and runs after - # merge instead, via workflow_dispatch, the main/preview `push:` below and - # the nightly schedule. - # - # `push:` is pinned to the release lines. dev is deliberately absent: - # cross-platform evidence for dev is run by hand via workflow_dispatch. - # main and preview MUST stay: release.yml requires a successful push-event - # Cross-platform CI run for the exact release SHA and states that a - # pull-request run does not qualify, so removing either branch breaks - # publication. - push: - branches: [main, preview] - paths: - - "Dockerfile" - - "compose.yaml" - - ".dockerignore" - - "docker/**" - - "src/**" - - "bin/**" - - "tests/**" - - "scripts/**" - - "app/**" - - "desktop/**" - - "gui/**" - - "assets/**" - - ".github/scripts/**" - - ".github/workflows/**" - - ".gitattributes" - - ".npmignore" - - "package.json" - - "bun.lock" - - "tsconfig.json" - - "README.md" - - "readme/**" - - "skills/**" - - ".github/ISSUE_TEMPLATE/**" - - "LICENSE" + # Pull requests run only the fast Ubuntu subset. Cross-platform jobs run + # after merge by a manual workflow_dispatch on dev. workflow_dispatch: inputs: lane: - description: "all (default), release-gates, or macos-control" + description: "all (default), release-gates, macos-control, or windows-native-claim" type: choice default: all - options: [all, release-gates, macos-control] - # Nightly safety net. The Windows shards run on a pull request only when the - # changes job judges it Windows-sensitive, so anything that slips past that - # filter must still be caught within a day: a scheduled run requests the full - # lane=all matrix (eleven Windows shards and the macOS control included). - # - # GitHub runs schedules from the DEFAULT branch, which is main, so this - # trigger activates only once this file reaches main by promotion, and the - # workflow definition is main's. Every test checkout reads - # needs.changes.outputs.checkout_ref, which the changes job resolves to ONE - # dev commit on a schedule, so the run tests dev and every job tests the same - # SHA. Its check runs attach to main's head commit; release.yml accepts only a - # push-event run, so a scheduled run can never qualify a release. - schedule: - - cron: "17 18 * * *" + options: [all, release-gates, macos-control, windows-native-claim] permissions: contents: read concurrency: - # `push` and `pull_request` want supersession: a newer head on the same ref + # Pull requests want supersession: a newer head on the same ref # makes the older one irrelevant, and cancelling it saves an hour of runners # for an answer nobody will read. # @@ -113,9 +63,8 @@ concurrency: # cancels nothing and nothing cancels it, including a second dispatch of the # same ref. # - # A scheduled run is keyed the same way. Keyed on the ref it would share - # refs/heads/main with a promotion push and, with cancel-in-progress, cancel - # the very run release.yml waits for. + # The schedule branch of this expression is retained for selector compatibility; + # this workflow no longer declares a schedule trigger. group: cross-platform-ci-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'schedule' && github.run_id || github.ref }} cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' }} @@ -144,12 +93,8 @@ jobs: # honest pull requests on GitHub-hosted runners and lets trusted branch runs # avoid the hosted-Windows Bun crashes. It is not the security boundary. # - # `push` on main/preview requires the push permission, and - # `workflow_dispatch` requires write access, so both carry a trusted author. - # A schedule runs main's workflow against a dev commit that already merged - # through review, the same trust as a push. - # A trusted author is not audited code: merging a contributor PR into `dev` - # fires `push`, and its dependencies and postinstall hooks then run here. + # `workflow_dispatch` requires write access, but a trusted author does not + # make candidate dependencies or postinstall hooks independently audited. select-windows-runner: name: select windows runner runs-on: ubuntu-latest @@ -158,6 +103,17 @@ jobs: runner: ${{ steps.pick.outputs.runner }} label: ${{ steps.pick.outputs.label }} steps: + # This job also runs when the diagnostic lane skips changes. + - name: Validate dispatch source + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + run: | + if [ "$EVENT_NAME" = workflow_dispatch ] && [ "$REF" != refs/heads/dev ]; then + echo '::error::manual cross-platform CI must run from dev' + exit 1 + fi - name: Pick runner id: pick env: @@ -188,7 +144,7 @@ jobs: echo 'label=windows-latest' >> "$GITHUB_OUTPUT" fi - # Which areas this push actually touches. + # Which areas the pull request actually touches. # # Deliberately a job-level filter rather than a wider workflow-level `paths:` # one. A workflow skipped by path filtering leaves its checks Pending forever, @@ -197,6 +153,7 @@ jobs: # in the `on:` block above. changes: name: changes + if: github.event_name != 'workflow_dispatch' || github.event.inputs.lane != 'windows-native-claim' runs-on: ubuntu-latest timeout-minutes: 5 # The workflow grants only `contents: read`, and specifying any permission @@ -219,20 +176,18 @@ jobs: # shape-checked by the same validation step as `native`. keyring_matrix: ${{ steps.matrices.outputs.keyring_matrix }} npm_global_matrix: ${{ steps.matrices.outputs.npm_global_matrix }} - # A scheduled run starts from main, so its path filter diffs main and would - # scope jobs by the wrong commit. The nightly safety net selects every scope - # instead, here and in the validation steps below, so it is the full matrix. - gui: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.gui }} - packaging: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.packaging }} - docs: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.docs }} - structure: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.structure }} + # A full manual dispatch selects every scope regardless of path filters. + gui: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')) && 'true' || steps.filter.outputs.gui }} + packaging: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')) && 'true' || steps.filter.outputs.packaging }} + docs: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')) && 'true' || steps.filter.outputs.docs }} + structure: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')) && 'true' || steps.filter.outputs.structure }} # Narrow scopes for two paths the ci filter leaves out on purpose. Both are re-emitted by the # validation step below, so a malformed filter output fails this job instead of silently # skipping the check it selects. setup_action: ${{ steps.narrow.outputs.setup_action }} remote_helper: ${{ steps.narrow.outputs.remote_helper }} # Pull-request scope for the two smoke jobs that used to ride the whole ci filter. - # Push, dispatch and schedule run both regardless; see the docker/keyring filters. + # Manual dispatch runs both regardless; see the docker/keyring filters. docker: ${{ steps.narrow.outputs.docker }} keyring: ${{ steps.narrow.outputs.keyring }} # Whether this pull request needs the Windows shards: the windows path @@ -244,6 +199,16 @@ jobs: # except on a schedule, where it is the one dev SHA resolved below. checkout_ref: ${{ steps.nightly.outputs.checkout_ref }} steps: + - name: Validate dispatch source + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + run: | + if [ "$EVENT_NAME" = workflow_dispatch ] && [ "$REF" != refs/heads/dev ]; then + echo '::error::manual cross-platform CI must run from dev' + exit 1 + fi - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: @@ -260,17 +225,18 @@ jobs: uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: # Without this the action compares against the repository's DEFAULT - # branch, which is `main`. A push to `dev` would then be diffed against - # `main`, so every area touched since the last promotion keeps reading - # as "changed" — the scoped jobs would run on nearly every dev push and - # the saving would silently not happen while CI stayed green. + # branch, which is `dev`. For a non-PR event that diff starts at + # `dev` itself, so every area touched since the branch point keeps + # reading as "changed" — the scoped jobs would run on nearly every + # such event and the saving would silently not happen while CI stayed + # green. # # On `pull_request` the action ignores this and uses the PR's own file - # list. On a branch push it means "compare against the previous commit - # on this branch", which is the intent. + # list. Otherwise `github.ref` keeps the diff scoped to this ref, which + # is the intent. base: ${{ github.ref }} filters: | - # Mirrors the push trigger's path allowlist. Pull requests always + # Pull requests always # start the workflow so the aggregate check exists, while these # paths decide whether the expensive test jobs need to run. ci: @@ -325,8 +291,7 @@ jobs: # assembled or launched: the shell itself, the standalone sidecar build # and its runtime locator, the native keyring staging, and the dependency # set. Ordinary src/** and gui/** changes no longer select it on a pull - # request; they are still covered on every promotion push to main and - # preview and by workflow_dispatch, which always request this job. + # request; a full manual dispatch requests this job after merge. # The workflow names itself so edits to this lane cannot skip their own E2E. desktop: - 'desktop/**' @@ -341,11 +306,11 @@ jobs: - '.github/workflows/ci.yml' # The docs site is built by nothing else on a pull request. `ci` above # deliberately omits `docs-site/**` -- a prose edit has no business - # starting the cross-platform suite -- and `deploy-docs.yml` triggers - # only on `push` to `main`. That left the Astro toolchain with no - # pull-request build gate at all, so a dependency bump under - # `docs-site/` could only be proven by an author's local run and would - # otherwise surface at promotion. + # starting the cross-platform suite -- and `deploy-docs.yml` is + # dispatch-only. That left the Astro toolchain with no pull-request + # build gate at all, so a dependency bump under `docs-site/` could + # only be proven by an author's local run and would otherwise surface + # at release time. # # `.github/workflows/ci.yml` is here so an edit to the job below # verifies itself. Without it this filter's own pull request would @@ -369,11 +334,8 @@ jobs: # `.github/workflows/ci.yml` is listed for the same reason `docs` # lists it: without it this filter's own pull request would skip the # job it adds. - # This list stays PULL-REQUEST scope only, like `docs` above: the - # push trigger's `paths:` is pinned to mirror `ci` exactly, and - # `dev`, `main` and `preview` are all protected to require a pull - # request, so every `structure/` change is read at the pull request - # before it can reach an integration line. + # This list keeps pull-request scope narrow; full manual dispatches + # also select the structure gate. structure: - 'structure/**' - '.github/workflows/ci.yml' @@ -394,8 +356,7 @@ jobs: # ci filter matched, which is nearly all of them, while each proves something narrow: # the image builds, starts and survives recreation with its volume; the OS keyring # binding creates, reads and deletes a secret. A pull request now runs each only when - # its own inputs move. Push to main/preview, workflow_dispatch and the nightly still - # request both unconditionally, so the release gate's push run always carries them. + # its own inputs move. Manual dispatch requests both unconditionally. # ci.yml is listed so an edit to either job verifies itself. The setup action is left to # its own setup-action job, as the docs and structure filters leave it. docker: @@ -479,8 +440,8 @@ jobs: id: scope shell: bash env: - CI_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.ci }} - DESKTOP_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.desktop }} + CI_SCOPE: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')) && 'true' || steps.filter.outputs.ci }} + DESKTOP_SCOPE: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')) && 'true' || steps.filter.outputs.desktop }} run: | set -euo pipefail case "$CI_SCOPE" in @@ -506,7 +467,7 @@ jobs: id: matrices shell: bash env: - NATIVE_SELECTED: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.native }} + NATIVE_SELECTED: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')) && 'true' || steps.filter.outputs.native }} EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail @@ -570,8 +531,8 @@ jobs: id: narrow shell: bash env: - SETUP_ACTION: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.setup_action }} - REMOTE_HELPER: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.remote_helper }} + SETUP_ACTION: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')) && 'true' || steps.filter.outputs.setup_action }} + REMOTE_HELPER: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')) && 'true' || steps.filter.outputs.remote_helper }} DOCKER_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.docker }} KEYRING_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.keyring }} run: | @@ -605,9 +566,10 @@ jobs: PATH_SELECTED: ${{ steps.filter.outputs.windows }} run: bash scripts/ci/windows-sensitive-diff.sh - # A scheduled run starts from main's head. Resolve dev ONCE so the nine - # Windows shards, the macOS control and every other job test one commit, - # however long the queue between them. contents: read already covers it. + # A scheduled run starts from the default branch's head, which is `dev`. + # Resolve dev ONCE so the nine Windows shards, the macOS control and every + # other job test one commit, however long the queue between them. + # contents: read already covers it. - name: Resolve the commit a scheduled run tests id: nightly shell: bash @@ -651,11 +613,17 @@ jobs: # Only the suite lives here. Typecheck, lint, build, and the scans run once in # `gates` rather than five times — they are fixed cost, and paying it per shard # would eat what the sharding saves. + # The heavy Linux jobs (test shards, test-changed, gates) take the lidge self-hosted pool on + # trusted runs while the repository variable LIDGE_RUNNERS is on: pushes, manual runs, branches of + # this repository, and pull requests by owners, members or collaborators. Everything else, and every + # run while the variable is off, stays on ubuntu-latest, so the hosted runners keep carrying the light + # jobs. As with the Windows box above, this routing is operational; the fork-PR approval policy and + # the box's scaler, which only creates runners for trusted runs, are the security boundary. test: name: test ${{ matrix.shard }}/5 needs: changes if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.fast != 'true') - runs-on: ubuntu-latest + runs-on: ${{ vars.LIDGE_RUNNERS == 'on' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)) && fromJSON('["self-hosted","linux","x64","lidge"]') || 'ubuntu-latest' }} # A fifth of the suite. A shard that needs longer than this is wedged, not # slow — the old 30-minute ceiling was margin for the Windows leg, which no # longer runs here (see platform-windows). @@ -719,7 +687,7 @@ jobs: name: test changed needs: changes if: github.event_name == 'pull_request' && needs.changes.outputs.fast == 'true' - runs-on: ubuntu-latest + runs-on: ${{ vars.LIDGE_RUNNERS == 'on' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)) && fromJSON('["self-hosted","linux","x64","lidge"]') || 'ubuntu-latest' }} timeout-minutes: 30 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -813,7 +781,7 @@ jobs: name: gates needs: changes if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' - runs-on: ubuntu-latest + runs-on: ${{ vars.LIDGE_RUNNERS == 'on' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)) && fromJSON('["self-hosted","linux","x64","lidge"]') || 'ubuntu-latest' }} timeout-minutes: 15 steps: - name: Checkout @@ -824,6 +792,8 @@ jobs: # residue. Matches the convention already used by the other workflows. persist-credentials: false ref: ${{ needs.changes.outputs.checkout_ref }} + # Release history tests verify old tag and dev-baseline trees. + fetch-depth: 0 - name: Setup project Bun uses: ./.github/actions/setup-project-bun @@ -857,6 +827,9 @@ jobs: - name: Check the generated ocx skill surface is current run: bun run skill:surface:check + - name: Test release evidence, history and docs deployment + run: node --test .github/scripts/release-dispatch-guard.test.cjs .github/scripts/release-evidence.test.cjs .github/scripts/release-history.test.cjs .github/scripts/release-resume.test.cjs .github/workflows/deploy-docs.test.cjs + - name: Check release helper syntax run: bun build scripts/release.ts --target=bun --outdir=.tmp/ci-release-script-check @@ -887,8 +860,7 @@ jobs: platform-macos: name: macos ${{ matrix.shard }}/4 needs: changes - # Never on pull requests: macOS legs run after merge, on push to - # main/preview, and on dispatch and the schedule. + # Never on pull requests: macOS legs run through manual dispatch. if: github.event_name != 'pull_request' runs-on: macos-latest # Four shards. Three replaced two after run 38214884055 hit this cap with @@ -970,7 +942,6 @@ jobs: needs: changes # Diagnostic-only lanes stay off the ordinary release gates: list every lane # that should run this job, so a new lane defaults to skipping the diagnostic. - # The nightly schedule is the full lane=all matrix, so it runs this job too. if: >- github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all' || github.event.inputs.lane == 'macos-control') runs-on: macos-latest @@ -1036,34 +1007,41 @@ jobs: - name: CLI help smoke run: bun run src/cli/index.ts help - # Windows runs on a Windows-sensitive pull request, on the nightly schedule, - # and on a lane=all (or default) dispatch. It is never part of the push run - # release.yml gates on. - # - # It left the PR lane first (16m23s against a 6-minute Linux critical path; - # the ceiling was raised twice rather than the gap closed — #711 vs #653, - # issue #717, then #827's 20-minute kill), then left the shipping boundary - # when the sharded promotion run surfaced ~207 Windows-only test failures - # that had been invisible while dev skipped the leg. Those are real defects, - # tracked as issue #1059, but they are pre-existing on every released - # version — gating the release on them blocks shipping fixes to the platforms - # that pass, for a platform that has never shipped green. - # - # The leg stays in the workflow, sharded and dispatchable, so the failure - # list can be burned down without losing the ability to measure progress. - # release.yml gates on a successful push-event run of this workflow, which - # now means Linux + macOS + the gates; Windows re-enters the gate when the - # tracked failures are fixed, not before. - # - # Pull requests never run these shards (AGENTS.md "CI policy", a maintainer - # decision): Windows runs on the nightly schedule and on a lane=all dispatch. - # The changes job still computes windows=true for a Windows-sensitive pull - # request (a Windows path, or an added src/tests line naming a Windows marker; - # see scripts/ci/windows-sensitive-diff.sh). That verdict does not schedule - # these jobs; it posts a non-blocking notice, and a maintainer dispatches - # lane=all on the branch before merging. #6639, #6556, #6605, #6606 and #7468 - # each landed with green pull-request CI while breaking Windows, which is why - # that dispatch is part of merging a Windows-sensitive pull request. + # A diagnostic run deliberately bypasses changes and the full CI graph. Its + # title identifies it as a partial run, never release evidence. + windows-native-claim: + name: windows native-main claim diagnostic + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev' && github.event.inputs.lane == 'windows-native-claim' + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout workflow commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + with: + role: test-runner + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Isolate homes + shell: pwsh + run: | + $root = Join-Path $env:RUNNER_TEMP "ocx-claim-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + $ocxHome = Join-Path $root 'opencodex' + $codexHome = Join-Path $root 'codex' + New-Item -ItemType Directory -Path $ocxHome, $codexHome -ErrorAction Stop | Out-Null + "OPENCODEX_HOME=$ocxHome" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "CODEX_HOME=$codexHome" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Run native-main claim diagnostics + env: + OCX_TEST_WINDOWS_NATIVE_CLAIM_DIAGNOSTIC: '1' + run: bun test tests/codex-integration/main-quota-provenance.test.ts --timeout 60000 + + # Windows shards run only on a full manual dispatch. Pull requests retain + # fast Ubuntu checks, including when the Windows-sensitive filter is set. platform-windows: name: windows ${{ matrix.shard }}/11 needs: [changes, select-windows-runner] @@ -1283,7 +1261,7 @@ jobs: run: bun scripts/ci/docker-smoke.ts # Proves the Astro toolchain still builds the site, on the only event that can - # prove it before promotion. + # prove it before release. # # Linux only, and one leg. The site is static output from a Node/Bun toolchain # with no OS-specific behaviour to promise, so a Windows or macOS leg would buy @@ -1556,7 +1534,7 @@ jobs: name: macos widget + bundle needs: [changes, gates] # Never on pull requests, like platform-macos: the widget extension and - # app bundle are rebuilt after merge, on push, dispatch and the schedule. + # app bundle are rebuilt through manual dispatch. if: github.event_name != 'pull_request' runs-on: macos-latest timeout-minutes: 30 @@ -1758,7 +1736,7 @@ jobs: ci: name: ci - if: always() + if: always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.lane != 'windows-native-claim') # Every producer, including the ones that only feed other jobs. `needs` holds # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report diff --git a/.github/workflows/cleanup-closed-pr-branches.yml b/.github/workflows/cleanup-closed-pr-branches.yml index 4b0c1229a9f..8cbca5076b9 100644 --- a/.github/workflows/cleanup-closed-pr-branches.yml +++ b/.github/workflows/cleanup-closed-pr-branches.yml @@ -5,9 +5,8 @@ name: Clean branches from closed pull requests # merging leaves its head branch behind forever, which is how this repository # accumulated dozens of dead `codex/*` and `ingw/*` branches. # -# Scheduled workflows only run from the repository DEFAULT branch (currently -# `main`), not from `dev`. Landing this on `dev` alone does not start the -# cleanup until the change is also promoted to that default branch. +# Scheduled workflows only run from the repository DEFAULT branch, which is +# `dev`. A change lands on the live cleanup as soon as it is on `dev`. on: schedule: # Daily at 06:30 UTC (offset from the hour to reduce Action load spikes). diff --git a/.github/workflows/cleanup-orphaned-workflows.yml b/.github/workflows/cleanup-orphaned-workflows.yml index aef4abae90b..2ee4f23d7c0 100644 --- a/.github/workflows/cleanup-orphaned-workflows.yml +++ b/.github/workflows/cleanup-orphaned-workflows.yml @@ -7,13 +7,6 @@ on: schedule: # Daily at 06:00 UTC. - cron: "0 6 * * *" - push: - branches: [main] - paths: - # The first promotion to main performs the initial backlog cleanup without - # exposing a branch-selectable manual trigger with actions:write. - - ".github/workflows/cleanup-orphaned-workflows.yml" - - "scripts/ci/cleanup-orphaned-workflows.mjs" permissions: actions: write # Required to delete orphaned workflow runs through the Actions API. diff --git a/.github/workflows/codex-queue-helpers.yml b/.github/workflows/codex-queue-helpers.yml index 62b5991b793..7145a1fa1a9 100644 --- a/.github/workflows/codex-queue-helpers.yml +++ b/.github/workflows/codex-queue-helpers.yml @@ -7,14 +7,6 @@ on: - 'docs-site/astro.config.mjs' - 'docs-site/src/content/docs/guides/composer-usage-gate-fallback.md' - '.github/workflows/codex-queue-helpers.yml' - push: - branches: [main, preview] - paths: - - 'scripts/codex-queue.*' - - 'docs-site/astro.config.mjs' - - 'docs-site/src/content/docs/guides/composer-usage-gate-fallback.md' - - '.github/workflows/codex-queue-helpers.yml' - workflow_dispatch: permissions: diff --git a/.github/workflows/deploy-docs.test.cjs b/.github/workflows/deploy-docs.test.cjs new file mode 100644 index 00000000000..6343c63e1ac --- /dev/null +++ b/.github/workflows/deploy-docs.test.cjs @@ -0,0 +1,64 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const workflow = fs.readFileSync(path.join(__dirname, 'deploy-docs.yml'), 'utf8'); +function scriptFor(step) { + const start = workflow.indexOf(` - name: ${step}\n`); + assert.notEqual(start, -1); + const rest = workflow.slice(start); + const match = rest.match(/\n run: \|\n((?: .*\n|\n)+)/); + assert.ok(match); + return match[1].replace(/^ /gm, ''); +} +const selection = scriptFor('Resolve published stable source'); +const guard = scriptFor('Require trusted dispatch'); +const sha = 'a'.repeat(40); +function run(script, overrides = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ocx-pages-test-')); + try { + fs.writeFileSync(path.join(dir, 'gh'), '#!/bin/bash\nprintf "%s" "$MOCK_RELEASE"\n', { mode: 0o755 }); + fs.writeFileSync(path.join(dir, 'git'), '#!/bin/bash\nif [ "$MOCK_GIT_FAIL" = 1 ]; then exit 1; fi\nprintf "%s\\n" "$MOCK_SHA"\n', { mode: 0o755 }); + const output = path.join(dir, 'output'); + fs.writeFileSync(output, ''); + const env = { ...process.env, PATH: `${dir}:${process.env.PATH}`, GITHUB_OUTPUT: output, + GITHUB_STEP_SUMMARY: path.join(dir, 'summary'), GITHUB_REPOSITORY: 'owner/repo', + GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REF: 'refs/heads/dev', RELEASE_TAG: '', + MOCK_RELEASE: JSON.stringify({ tag_name: 'v2.3.4', draft: false, prerelease: false }), MOCK_SHA: sha, + ...overrides }; + const result = spawnSync('bash', ['-c', script], { env, encoding: 'utf8' }); + return { ...result, output: fs.readFileSync(output, 'utf8') }; + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +} +test('only trusted dev dispatch reaches the build', () => { + assert.equal(run(guard).status, 0); + assert.notEqual(run(guard, { GITHUB_REF: 'refs/heads/pr-head' }).status, 0); + assert.notEqual(run(guard, { GITHUB_EVENT_NAME: 'push' }).status, 0); + assert.doesNotMatch(workflow, /^ push:/m); + assert.match(workflow, /ref: \$\{\{ steps\.source\.outputs\.sha \}\}/); + assert.match(workflow, /permissions:\n pages: write\n id-token: write/); +}); +test('latest and explicit published stable release select immutable commit', () => { + const latest = run(selection); + assert.equal(latest.status, 0, latest.stderr); + assert.equal(latest.output, `sha=${sha}\n`); + const explicit = run(selection, { RELEASE_TAG: 'v2.3.4', MOCK_RELEASE: JSON.stringify({ tagName: 'v2.3.4', isDraft: false, isPrerelease: false }) }); + assert.equal(explicit.status, 0, explicit.stderr); + assert.equal(explicit.output, `sha=${sha}\n`); +}); +test('refuses prereleases, drafts, mismatch, malformed tags and missing commits', () => { + for (const overrides of [ + { RELEASE_TAG: 'v2.3.4-preview.1' }, { RELEASE_TAG: '$(touch evil)' }, + { MOCK_RELEASE: JSON.stringify({ tag_name: 'v2.3.4', draft: true, prerelease: false }) }, + { MOCK_RELEASE: JSON.stringify({ tag_name: 'v2.3.4', draft: false, prerelease: true }) }, + { RELEASE_TAG: 'v2.3.4', MOCK_RELEASE: JSON.stringify({ tagName: 'v2.3.5', isDraft: false, isPrerelease: false }) }, + { MOCK_GIT_FAIL: '1' }, { MOCK_SHA: 'bad' }, { MOCK_RELEASE: '{}' }, + ]) { + const result = run(selection, overrides); + assert.notEqual(result.status, 0, JSON.stringify(overrides)); + assert.equal(result.output, ''); + } +}); diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 9853a0b2744..c726f026773 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -1,17 +1,14 @@ name: Deploy Docs to GitHub Pages on: - push: - branches: [main] - paths: - - "docs-site/**" - - ".github/workflows/deploy-docs.yml" workflow_dispatch: + inputs: + release-tag: + description: "Published stable release tag (empty selects the latest stable release)" + required: false + type: string -permissions: - contents: read - pages: write - id-token: write +permissions: {} concurrency: group: pages @@ -21,9 +18,57 @@ jobs: build: runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read steps: - - name: Checkout + - name: Require trusted dispatch + run: | + test "$GITHUB_EVENT_NAME" = workflow_dispatch && test "$GITHUB_REF" = refs/heads/dev || { + echo '::error::Dispatch the trusted workflow from dev.'; exit 1; + } + - name: Checkout trusted workflow revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Resolve published stable source + id: source + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release-tag }} + run: | + set -euo pipefail + if [ -n "$RELEASE_TAG" ]; then + [[ "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { + echo '::error::Expected a stable release tag such as v1.2.3.'; exit 1; + } + release="$(gh release view "$RELEASE_TAG" --json tagName,isDraft,isPrerelease)" + else + release="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest")" + fi + tag="$(RELEASE_JSON="$release" node - <<'NODE' + const r = JSON.parse(process.env.RELEASE_JSON); + const tag = r.tagName ?? r.tag_name; + const draft = r.isDraft ?? r.draft; + const prerelease = r.isPrerelease ?? r.prerelease; + if (draft !== false || prerelease !== false || typeof tag !== 'string' || + !/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tag) || + (process.env.RELEASE_TAG && tag !== process.env.RELEASE_TAG)) { + throw new Error('Release is not the requested published stable release'); + } + process.stdout.write(tag); + NODE + )" + sha="$(git rev-parse --verify "refs/tags/${tag}^{commit}")" + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::Invalid release commit.'; exit 1; } + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "Building published release $tag at $sha" >> "$GITHUB_STEP_SUMMARY" + - name: Checkout published source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ steps.source.outputs.sha }} + persist-credentials: false - name: Build with Astro uses: withastro/action@e84f40bd8d2caa9e768ec82ad30dd81f0b280853 # v6.1.2 with: @@ -36,6 +81,9 @@ jobs: needs: build runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/dev-version-bump.yml b/.github/workflows/dev-version-bump.yml index 99a8946c3e4..cd99285c430 100644 --- a/.github/workflows/dev-version-bump.yml +++ b/.github/workflows/dev-version-bump.yml @@ -19,8 +19,8 @@ name: Dev version bump # # A branch-selected dispatch executes that branch's workflow body with write permission. # The in-job guard therefore rejects accidental non-default-ref dispatches. It is an early -# warning, not a security boundary: a writer could remove it on their branch. Protected -# release branches and the required review on `dev` remain the enforcement boundaries. +# warning, not a security boundary: a writer could remove it on their branch. The protected +# `dev` branch and its required review remain the enforcement boundaries. on: workflow_dispatch: inputs: diff --git a/.github/workflows/enforce-issue-quality.yml b/.github/workflows/enforce-issue-quality.yml index a983da5d781..1531fb79f8b 100644 --- a/.github/workflows/enforce-issue-quality.yml +++ b/.github/workflows/enforce-issue-quality.yml @@ -1,9 +1,8 @@ name: Enforce issue quality -# Issue events always load this workflow from the repository DEFAULT branch -# (currently `main`), not from `dev`. Landing here on `dev` alone does not -# change live issue-quality behavior until the change is also on that default -# branch. +# Issue events always load this workflow from the repository DEFAULT branch, +# which is `dev`. A change lands on the live issue-quality gate as soon as it is +# on `dev`. on: issues: types: diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index d92331c8a90..2db9844da17 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -193,17 +193,16 @@ jobs: # # The branch is chosen, not fixed. `status` has no pull_request # payload and runs the privileged workflow from the repository - # default branch, so it sources from that same promoted boundary. A - # `main`-targeting PR must take its scripts from `main`. The + # default branch, so it sources from that same trusted boundary. The # donadiosolutions fork also trusts its `custom` integration branch; # other bases, including stacked children, resolve to `dev`. - ref: ${{ github.event_name == 'status' && github.event.repository.default_branch || (github.event.pull_request.base.ref == 'main' && 'main' || (github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev')) }} + ref: ${{ github.event_name == 'status' && github.event.repository.default_branch || (github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev') }} persist-credentials: false sparse-checkout: | .github/scripts MAINTAINERS.md - - name: Enforce PR target, ancestry, and description + - name: Enforce PR target and description uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: RESOLVED_PULL_NUMBER: ${{ needs.resolve-pr.outputs.pull-number }} @@ -685,9 +684,7 @@ jobs: gateState.pendingReattestation = null; } - let behindMain = 0; let behindBase = 0; - let aheadMain = 0; let ancestryLookupFailed = false; const baseAllowed = ALLOWED_BASES.includes(pr.base.ref); @@ -727,15 +724,9 @@ jobs: if (baseAllowed) { const headSha = pr.head.sha; try { - const { data: mainCompare } = - await github.rest.repos.compareCommitsWithBasehead({ - owner, - repo, - basehead: `main...${headSha}` - }); - behindMain = mainCompare.behind_by; - aheadMain = mainCompare.ahead_by; - + // Compare against the accepted integration base for the + // review-readiness claim. The retired `main` tip no longer + // produces a separate wrong-ancestry verdict. const { data: baseCompare } = await github.rest.repos.compareCommitsWithBasehead({ owner, @@ -807,12 +798,6 @@ jobs: allowedBases: ALLOWED_BASES, title: pr.title, body: pr.body, - behindMain, - behindBase, - aheadMain, - authorPermission, - permissionLookupFailed, - ancestryLookupFailed, stackedBase, // A maintainer issue comment ("not touching gui") waives the // GUI-screenshot gate; the comments are already fetched above. @@ -1265,11 +1250,6 @@ jobs: `Retarget this PR to ${inlineCode(DEFAULT_BASE)} — all contributions go to ${inlineCode(DEFAULT_BASE)}.` ); } - if (failures.some(failure => failure.code === "wrong_ancestry")) { - actions.push( - `Rebase onto the current ${inlineCode(DEFAULT_BASE)} branch instead of opening from ${inlineCode("main")}.` - ); - } if (failures.some(failure => failure.code === "bad_description")) { actions.push( "Add a real **Summary** and **Test plan** to the PR description." @@ -1398,9 +1378,6 @@ jobs: if (failure.code === "wrong_base") { return `wrong target branch (${pr.base.ref}); retarget to ${inlineCode(DEFAULT_BASE)}.`; } - if (failure.code === "wrong_ancestry") { - return "wrong branch ancestry; rebase onto the latest dev."; - } if (failure.code === "bad_description") { return `PR description needs work (${failure.reason}).`; } diff --git a/.github/workflows/issue-quality-tests.yml b/.github/workflows/issue-quality-tests.yml index fd064ce1013..3917fda15df 100644 --- a/.github/workflows/issue-quality-tests.yml +++ b/.github/workflows/issue-quality-tests.yml @@ -35,42 +35,6 @@ on: - ".github/workflows/issue-triage.yml" - ".github/workflows/issue-quality-tests.yml" - ".github/workflows/pr-hygiene.yml" - push: - # Release lines only; review is covered by the pull_request trigger above. - branches: [main, preview] - paths: - - ".github/ISSUE_TEMPLATE/**" - - ".github/scripts/issue-quality.cjs" - - ".github/scripts/issue-quality-core.cjs" - - ".github/scripts/issue-quality*.test.cjs" - - ".github/scripts/pr-quality.cjs" - - ".github/scripts/pr-quality.test.cjs" - - ".github/scripts/pr-quality-messages.cjs" - - ".github/scripts/pr-quality-messages.test.cjs" - - ".github/scripts/pr-quality-state.cjs" - - ".github/scripts/pr-readiness-reattest.cjs" - - ".github/scripts/pr-quality-state.test.cjs" - - ".github/scripts/pr-labeler.cjs" - - ".github/scripts/pr-labeler.test.cjs" - - ".github/scripts/enforce-pr-target.test.cjs" - - ".github/scripts/pr-hygiene.cjs" - - ".github/scripts/pr-hygiene.test.cjs" - - ".github/scripts/pr-sponsored-surface.cjs" - - ".github/scripts/pr-sponsored-surface.test.cjs" - - ".github/scripts/issue-translation.cjs" - - ".github/scripts/issue-translation.test.cjs" - - ".github/scripts/issue-triage*.cjs" - - ".github/scripts/copilot-workflows.test.cjs" - - ".github/scripts/install-copilot-cli.sh" - - ".github/scripts/run-copilot-inference*.cjs" - - ".github/scripts/parse-issue-translation-response.cjs" - - ".github/scripts/parse-issue-translation-response.test.cjs" - - ".github/workflows/enforce-issue-quality.yml" - - ".github/workflows/enforce-pr-target.yml" - - ".github/workflows/pr-labeler.yml" - - ".github/workflows/issue-triage.yml" - - ".github/workflows/issue-quality-tests.yml" - - ".github/workflows/pr-hygiene.yml" permissions: contents: read diff --git a/.github/workflows/pr-hygiene.yml b/.github/workflows/pr-hygiene.yml index 76d130fa2b7..e08cc458bed 100644 --- a/.github/workflows/pr-hygiene.yml +++ b/.github/workflows/pr-hygiene.yml @@ -41,12 +41,10 @@ jobs: # PR's head, so `base.sha` let an unpromoted commit choose which code # runs with this job's issues/pull-requests write token. # - # The branch is chosen, not fixed: `pull_request_target` loads this - # workflow from the default branch, so a `main`-targeting PR must - # take its scripts from `main` too, or the gate runs a `main` - # workflow against `dev` scripts. The donadiosolutions fork also - # trusts its `custom` integration branch. Stacked bases resolve to `dev`. - ref: ${{ github.event.pull_request.base.ref == 'main' && 'main' || (github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev') }} + # `dev` is the upstream integration line. The donadiosolutions fork + # also trusts its `custom` integration branch for fork-targeting PRs. + # Other bases, including stacked children, resolve to `dev`. + ref: ${{ github.repository == 'donadiosolutions/opencodex' && github.event.pull_request.base.ref == 'custom' && 'custom' || 'dev' }} persist-credentials: false sparse-checkout: .github/scripts diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 309fd40b8f1..f7c2b6fa63e 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -1,9 +1,8 @@ name: PR Labeler # pull_request_target always loads this workflow from the repository DEFAULT -# branch (currently `main`), not from `dev`. Landing here on `dev` alone does -# not change live labeler behavior until the change is also on that default -# branch — same promotion model as enforce-issue-quality.yml. +# branch, which is `dev`. A change lands on the live labeler as soon as it is on +# `dev` — same model as enforce-issue-quality.yml. on: pull_request_target: # labeled/unlabeled let a human type-label change enqueue a fresher run in the diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml index 93d8a38e3df..2efa81541c8 100644 --- a/.github/workflows/react-doctor.yml +++ b/.github/workflows/react-doctor.yml @@ -23,9 +23,6 @@ on: paths: - "gui/**" - ".github/workflows/react-doctor.yml" - # Scans `main` on every push to track the health-score trend. - push: - branches: ["main"] permissions: contents: read @@ -34,8 +31,8 @@ permissions: # and the CLI exits 1 on ENOENT even for fork PRs. pull-requests: read -# Cancels any in-flight scan for the same PR (or branch, on push) the moment a -# new commit arrives, so only the latest run remains. +# Cancels any in-flight scan for the same PR the moment a new commit arrives, +# so only the latest run remains. concurrency: group: react-doctor-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 152d4a8adaa..a1b4c5000ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,15 +1,12 @@ name: Release -# Publish opencodex to npm — jawcode-style: triggered from the Actions tab with an explicit -# version, dist-tag, and a dry-run-first default. Bump package.json on main BEFORE dispatching -# (or use `bun run release `, which does the bump+commit+push+dispatch for you); the -# workflow verifies the version matches before publishing. +# Select a tested dev source with the workflow revision pinned independently from that source. on: workflow_dispatch: inputs: version: - description: "Version to publish — must equal package.json (e.g. 0.1.0)" - required: true + description: "Optional expected derived version; required original version when resuming" + required: false type: string tag: description: "npm dist-tag" @@ -25,13 +22,13 @@ on: type: boolean default: true resume-after-npm-publish: - description: "Operator attestation: a previous run of this workflow acknowledged npm publication for this exact commit; skip npm publish and complete the GitHub side" + description: "Attest npm publication was acknowledged for the exact supplied source and version; skip npm publish" required: false type: boolean default: false expected-sha: - description: "Immutable release commit this dispatch must publish (fail if the branch moved)" - required: true + description: "Optional exact full source SHA; it must have qualifying dev CI evidence" + required: false type: string permissions: {} @@ -45,39 +42,121 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + actions: read + outputs: + source_sha: ${{ steps.source.outputs.source_sha }} + workflow_sha: ${{ steps.source.outputs.workflow_sha }} + version: ${{ steps.version.outputs.version }} steps: - - name: Checkout trusted dispatch guard + - name: Checkout trusted workflow revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.event.repository.default_branch }} + ref: ${{ github.sha }} persist-credentials: false - path: trusted-dispatch + fetch-depth: 0 - - name: Validate release dispatch + - name: Resolve eligible source + id: source env: - EXPECTED_SHA: ${{ inputs.expected-sha }} + GH_TOKEN: ${{ github.token }} + REQUESTED_SHA: ${{ inputs.expected-sha }} + REQUESTED_VERSION: ${{ inputs.version }} + RESUME: ${{ inputs.resume-after-npm-publish }} + DRY_RUN: ${{ inputs.dry-run }} run: | + set -euo pipefail + test "$GITHUB_EVENT_NAME" = workflow_dispatch && test "$GITHUB_REF" = refs/heads/dev || { + echo '::error::Release workflow must be dispatched from trusted dev.'; exit 1; + } node - <<'NODE' - const { validateReleaseDispatch } = require( - "./trusted-dispatch/.github/scripts/release-dispatch-guard.cjs", - ); - - const failure = validateReleaseDispatch({ - eventName: process.env.GITHUB_EVENT_NAME, - ref: process.env.GITHUB_REF, - expectedSha: process.env.EXPECTED_SHA, - actualSha: process.env.GITHUB_SHA, - }); - - if (failure) { - console.error(`::error::${failure}`); - process.exit(1); + const { validateReleaseDispatch } = require('./.github/scripts/release-dispatch-guard.cjs'); + const failure = validateReleaseDispatch({ eventName: process.env.GITHUB_EVENT_NAME, + ref: process.env.GITHUB_REF, sourceSha: process.env.REQUESTED_SHA, workflowSha: process.env.GITHUB_SHA }); + if (failure) throw new Error(failure); + NODE + if [ "$RESUME" = true ]; then + if [ -z "$REQUESTED_SHA" ] || [ -z "$REQUESTED_VERSION" ] || [ "$DRY_RUN" != false ]; then + echo '::error::Resume requires the original source SHA and version and cannot be a dry run.'; exit 1 + fi + fi + if [ -n "$REQUESTED_SHA" ]; then + result="$(node .github/scripts/release-evidence.cjs resolve "$REQUESTED_SHA")" + else + result="$(node .github/scripts/release-evidence.cjs resolve)" + fi + RESULT="$result" node - <<'NODE' + const fs = require('node:fs'); + const result = JSON.parse(process.env.RESULT); + if (!/^[0-9a-f]{40}$/.test(result.sourceSha) || !Number.isSafeInteger(result.ciRunId) || result.ciRunId <= 0 || + (process.env.REQUESTED_SHA && result.sourceSha !== process.env.REQUESTED_SHA)) { + throw new Error('Invalid source evidence'); } + fs.appendFileSync(process.env.GITHUB_OUTPUT, `source_sha=${result.sourceSha}\nworkflow_sha=${process.env.GITHUB_SHA}\n`); NODE + - name: Checkout selected source for version resolution + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ steps.source.outputs.source_sha }} + path: candidate + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Resolve release version + id: version + env: + CHANNEL: ${{ inputs.tag }} + EXPECTED_VERSION: ${{ inputs.version }} + RESUME: ${{ inputs.resume-after-npm-publish }} + run: | + set -euo pipefail + bun scripts/release-version-sources.ts check --root candidate + if [ "$RESUME" = true ]; then + # JavaScript template literals are evaluated by Bun, not the shell. + # shellcheck disable=SC2016 + bun -e ' + import { parseVersion } from "./scripts/version-line.ts"; + import { appendFileSync } from "node:fs"; + const value = process.env.EXPECTED_VERSION ?? "", channel = process.env.CHANNEL; + const parsed = parseVersion(value); + if (!/^\d+\.\d+\.\d+(?:-preview\.\d{8}(?:\.\d+)?)?$/.test(value) || !parsed || + !((channel === "latest" && parsed.prerelease === null) || + (channel === "preview" && parsed.prerelease?.[0] === "preview"))) throw Error("invalid original release version or channel"); + appendFileSync(process.env.GITHUB_OUTPUT, `version=${value}\n`); + ' + exit 0 + fi + npm view "$(node -p "require('./package.json').name")" versions --json > "$RUNNER_TEMP/release-npm-versions.json" + git tag --list 'v[0-9]*' > "$RUNNER_TEMP/release-tags.txt" + # JavaScript template literals are evaluated by Bun, not the shell. + # shellcheck disable=SC2016 + bun -e ' + import { devReleaseVersion, parseVersion } from "./scripts/version-line.ts"; + import { readFileSync, appendFileSync } from "node:fs"; + const versions = JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/release-npm-versions.json`, "utf8")); + if (!Array.isArray(versions) || versions.some(v => typeof v !== "string")) throw new Error("invalid npm versions"); + const tags = readFileSync(`${process.env.RUNNER_TEMP}/release-tags.txt`, "utf8").trim().split("\n").filter(Boolean).map(v => v.slice(1)); + const all = [...new Set([...versions, ...tags])]; + const stableTags = [], previewTags = []; + for (const version of all) { + const parsed = parseVersion(version); + if (!parsed) throw new Error(`invalid published version: ${version}`); + (parsed.prerelease === null ? stableTags : previewTags).push(version); + } + const channel = process.env.CHANNEL; + if (channel !== "latest" && channel !== "preview") throw new Error("invalid release channel"); + const base = JSON.parse(readFileSync("candidate/package.json", "utf8")).version; + const stamp = new Date().toISOString().slice(0, 10).replaceAll("-", ""); + const version = devReleaseVersion({ base, stableTags, previewTags, channel, stamp }); + if (process.env.EXPECTED_VERSION && process.env.EXPECTED_VERSION !== version) throw new Error("expected version differs from derived version"); + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`); + ' + # Every publication precondition the dispatch can already decide, checked before any runner # starts packaging: channel and dist-tag, every version source, the tag, the GitHub release, - # npm, the global tag ordering and the dev pre-move (scripts/ci/release-preflight.sh). + # npm and the global tag ordering (scripts/ci/release-preflight.sh). # # Run 35783865160 packaged 2.62.0 for nineteen minutes and then failed the ordering gate in # `publish` on v2.63.0-preview.20260923. That tag already existed when the run's first job @@ -96,23 +175,37 @@ jobs: timeout-minutes: 5 permissions: contents: read + env: + SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} + WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: + ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} persist-credentials: false + fetch-depth: 0 fetch-tags: true - name: Setup project Bun uses: ./.github/actions/setup-project-bun + - name: Stamp preflight version + run: | + set -euo pipefail + test -n "$SOURCE_SHA" && test -n "$WORKFLOW_SHA" && test -n "$RELEASE_VERSION" + test "$(git rev-parse HEAD)" = "$WORKFLOW_SHA" + bun scripts/release-version-sources.ts sync "$RELEASE_VERSION" + bun scripts/release-version-sources.ts check "$RELEASE_VERSION" + - name: Fetch the dev line run: git fetch --no-tags --depth=1 origin +refs/heads/dev:refs/remotes/origin/dev - name: Refuse a release that cannot publish env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} NPM_DIST_TAG: ${{ inputs.tag }} DRY_RUN: ${{ inputs.dry-run }} RESUME: ${{ inputs.resume-after-npm-publish }} @@ -120,6 +213,7 @@ jobs: package-standalone: needs: [validate-dispatch, preflight] + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev' }} strategy: fail-fast: false matrix: @@ -156,14 +250,35 @@ jobs: # actions: read lets the SignPath action read this job and download the unsigned artifact. actions: read contents: read + env: + SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} + WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: + ref: ${{ needs.validate-dispatch.outputs.source_sha }} + persist-credentials: false + + - name: Checkout trusted release policy + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} + path: trusted-release-policy persist-credentials: false - name: Setup project Bun - uses: ./.github/actions/setup-project-bun + uses: ./trusted-release-policy/.github/actions/setup-project-bun + + - name: Stamp and verify release version + shell: bash + run: | + set -euo pipefail + test -n "$SOURCE_SHA" && test -n "$WORKFLOW_SHA" && test -n "$RELEASE_VERSION" + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + bun trusted-release-policy/scripts/release-version-sources.ts sync "$RELEASE_VERSION" --root . + bun trusted-release-policy/scripts/release-version-sources.ts check "$RELEASE_VERSION" --root . # Windows Authenticode signing through SignPath Foundation (#7207). Decided before the build, so # a broken SignPath configuration fails in seconds instead of after the build. The rules @@ -201,7 +316,10 @@ jobs: OPENCODEX_HOME="$RUNNER_TEMP/ocx-home" "$binary" start --port 10177 >"$RUNNER_TEMP/ocx.log" 2>&1 & pid=$! trap 'kill "$pid" 2>/dev/null || true' EXIT - for _ in $(seq 1 30); do curl -fsS http://127.0.0.1:10177/healthz && break || sleep 1; done + for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:10177/healthz; then break; fi + sleep 1 + done curl -fsS http://127.0.0.1:10177/healthz test "$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:10177/)" = 200 @@ -276,12 +394,14 @@ jobs: - name: Archive standalone release shell: bash env: - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} STANDALONE_TARGET: ${{ matrix.target }} run: | set -euo pipefail cd "dist/standalone/$STANDALONE_TARGET" if [[ "$RUNNER_OS" == "Windows" ]]; then + # Variables in this literal belong to PowerShell. + # shellcheck disable=SC2016 powershell -NoProfile -Command 'Compress-Archive -Path ocx.exe,gui,keyring -DestinationPath ("../../ocx-{0}-{1}.zip" -f $env:RELEASE_VERSION,$env:STANDALONE_TARGET) -Force' else tar -czf "../../ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" ocx gui keyring @@ -308,6 +428,7 @@ jobs: package-desktop: needs: [validate-dispatch, preflight] + if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/dev' }} strategy: fail-fast: false matrix: @@ -341,6 +462,9 @@ jobs: actions: read contents: read env: + SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} + WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} # Whether this run holds the Developer ID material at all. A run without it still builds # locally useful bundles; a run with it must not silently downgrade any part of the app. DESKTOP_SIGNING_CONFIGURED: ${{ secrets.APPLE_CERTIFICATE != '' }} @@ -348,10 +472,27 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: + ref: ${{ needs.validate-dispatch.outputs.source_sha }} + persist-credentials: false + + - name: Checkout trusted release policy + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} + path: trusted-release-policy persist-credentials: false - name: Setup project Bun - uses: ./.github/actions/setup-project-bun + uses: ./trusted-release-policy/.github/actions/setup-project-bun + + - name: Stamp and verify release version + shell: bash + run: | + set -euo pipefail + test -n "$SOURCE_SHA" && test -n "$WORKFLOW_SHA" && test -n "$RELEASE_VERSION" + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + bun trusted-release-policy/scripts/release-version-sources.ts sync "$RELEASE_VERSION" --root . + bun trusted-release-policy/scripts/release-version-sources.ts check "$RELEASE_VERSION" --root . # Windows Authenticode signing through SignPath Foundation (#7207). Decided before the build, so # a broken SignPath configuration fails in seconds instead of after the build. The rules @@ -379,8 +520,8 @@ jobs: - name: Verify every version source matches the release shell: bash env: - RELEASE_VERSION: ${{ inputs.version }} - run: bun scripts/release-version-sources.ts check "$RELEASE_VERSION" + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} + run: bun trusted-release-policy/scripts/release-version-sources.ts check "$RELEASE_VERSION" --root . - name: Install project dependencies run: bun install --frozen-lockfile --os=${{ matrix.dependency_os }} --cpu=${{ matrix.dependency_cpu }} @@ -548,7 +689,7 @@ jobs: if: runner.os == 'Windows' shell: bash env: - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} run: bun desktop/scripts/windows-installer-config.ts "$RELEASE_VERSION" "$RUNNER_TEMP/opencodex-msi.json" - name: Preserve the compiled Linux sidecar @@ -706,7 +847,7 @@ jobs: - name: Rename release assets shell: bash env: - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} DESKTOP_TARGET: ${{ matrix.target }} run: | args=( \ @@ -785,6 +926,227 @@ jobs: security delete-keychain "${OPENCODEX_SIGNING_KEYCHAIN}" fi + # Candidate code and npm lifecycle scripts run only in this read-only job. + package-npm: + needs: [validate-dispatch, preflight] + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + outputs: + artifact_id: ${{ steps.upload.outputs.artifact-id }} + env: + SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} + WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} + steps: + - name: Checkout selected source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ needs.validate-dispatch.outputs.source_sha }} + persist-credentials: false + + - name: Checkout trusted release policy + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} + path: trusted-release-policy + persist-credentials: false + + - name: Setup project Bun + uses: ./trusted-release-policy/.github/actions/setup-project-bun + + - name: Stamp and verify release version + run: | + set -euo pipefail + test -n "$SOURCE_SHA" && test -n "$WORKFLOW_SHA" && test -n "$RELEASE_VERSION" + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + bun trusted-release-policy/scripts/release-version-sources.ts sync "$RELEASE_VERSION" --root . + bun trusted-release-policy/scripts/release-version-sources.ts check "$RELEASE_VERSION" --root . + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build npm package + run: | + set -euo pipefail + npm run prepublishOnly + npm run prepack + # All candidate lifecycle scripts have finished. Bind the packed manifest + # to the selected commit; npm pack below runs with scripts disabled. + node - <<'NODE' + const fs = require('node:fs'), { spawnSync } = require('node:child_process'); + const source = process.env.SOURCE_SHA; + if (!/^[0-9a-f]{40}$/.test(source || '')) throw Error('invalid source SHA'); + const git = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }); + if (git.status !== 0 || git.stdout.trim() !== source) throw Error('npm checkout source mismatch'); + const stat = fs.lstatSync('package.json'); + if (!stat.isFile() || stat.isSymbolicLink()) throw Error('invalid npm manifest'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + if (pkg.version !== process.env.RELEASE_VERSION) throw Error('npm manifest version mismatch'); + pkg.gitHead = source; + fs.writeFileSync('package.json.release-tmp', JSON.stringify(pkg, null, 2) + '\n', { flag: 'wx' }); + fs.renameSync('package.json.release-tmp', 'package.json'); + NODE + mkdir -p "$RUNNER_TEMP/npm-pack" dist/npm + npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/npm-pack" + shopt -s nullglob + packages=("$RUNNER_TEMP"/npm-pack/*.tgz) + test "${#packages[@]}" -eq 1 + cp -- "${packages[0]}" dist/npm/package.tgz + + - name: Upload npm package + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-package-unverified + path: dist/npm/package.tgz + if-no-files-found: error + retention-days: 7 + + # A fresh runner computes the receipt; candidate code cannot execute in this job. + verify-npm: + needs: [validate-dispatch, package-npm] + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + outputs: + artifact_id: ${{ steps.upload.outputs.artifact-id }} + sha256: ${{ steps.receipt.outputs.sha256 }} + env: + SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} + WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} + steps: + - name: Checkout trusted workflow revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} + persist-credentials: false + fetch-depth: 0 + + - name: Resolve trusted Bun version + id: bun-version + run: | + # JavaScript template literals are evaluated by Node, not the shell. + # shellcheck disable=SC2016 + node -e 'const v = require("./package.json").dependencies.bun; if (!/^\d+\.\d+\.\d+$/.test(v)) throw Error("invalid Bun pin"); require("node:fs").appendFileSync(process.env.GITHUB_OUTPUT, `version=${v}\n`);' + + - name: Setup trusted Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ steps.bun-version.outputs.version }} + + - name: Download npm package by immutable artifact ID + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.package-npm.outputs.artifact_id }} + path: npm-artifact + + - name: Recheck source CI and service evidence + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + node .github/scripts/release-evidence.cjs check "$SOURCE_SHA" + baseline_tag="$(git tag --merged "$SOURCE_SHA" --list 'v[0-9]*' | bun -e ' + import { parseVersion, compareVersions } from "./scripts/version-line.ts"; + const tags = (await Bun.stdin.text()).trim().split("\n").filter(Boolean); + const versions = tags.map(tag => ({ tag, version: tag.slice(1) })); + if (versions.some(item => !parseVersion(item.version))) throw new Error("invalid release tag"); + const eligible = versions.filter(item => compareVersions(item.version, process.env.RELEASE_VERSION) < 0); + eligible.sort((a, b) => compareVersions(a.version, b.version)); + console.log(eligible.at(-1)?.tag ?? ""); + ')" + baseline=--initial + if [ -n "$baseline_tag" ]; then + draft="$(gh release view "$baseline_tag" --json isDraft --jq .isDraft)" + test "$draft" = false || { echo '::error::Service baseline is not a published release.'; exit 1; } + baseline="$(git rev-parse "refs/tags/${baseline_tag}^{commit}")" + fi + range="$(node .github/scripts/release-evidence.cjs service-range "$SOURCE_SHA" "$baseline")" + required="$(RANGE="$range" node -e 'const x = JSON.parse(process.env.RANGE); if (typeof x.required !== "boolean") throw Error("invalid service range"); process.stdout.write(String(x.required));')" + if [ "$required" = true ]; then + node .github/scripts/release-evidence.cjs service "$SOURCE_SHA" || { + echo "::error::Dispatch service-lifecycle.yml from dev for source ${SOURCE_SHA} and wait for all platforms."; exit 1; + } + fi + + - name: Require reconciled publication history + if: ${{ inputs.resume-after-npm-publish != true }} + env: + GH_TOKEN: ${{ github.token }} + NPM_DIST_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if ! [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || [ -z "$RELEASE_VERSION" ]; then + echo '::error::Invalid release identity for history reconciliation.'; exit 1 + fi + case "$NPM_DIST_TAG" in + latest) channel=stable ;; + preview) channel=preview ;; + *) echo '::error::Invalid release channel.'; exit 1 ;; + esac + node .github/scripts/release-history.cjs check "$SOURCE_SHA" "$RELEASE_VERSION" "$channel" + + - name: Verify resume registry identity + id: resume-identity + if: ${{ inputs.resume-after-npm-publish == true }} + run: | + set -euo pipefail + pkg_name="$(node -p "require('./package.json').name")" + registry_version="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" version --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000)" || { + echo '::error::Cannot read the exact npm version; resume refused.'; exit 1; + } + REGISTRY_VERSION="$registry_version" node - <<'NODE' + const value = JSON.parse(process.env.REGISTRY_VERSION); + if (typeof value !== 'string' || value !== process.env.RELEASE_VERSION) throw Error('npm version does not match original release'); + NODE + registry_head="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" gitHead --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000)" || { + echo '::error::Cannot read npm gitHead; resume refused.'; exit 1; + } + bun scripts/verify-release-resume.ts "$SOURCE_SHA" "$registry_head" + echo "resume_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + + - name: Create verified npm receipt + id: receipt + run: | + set -euo pipefail + node - <<'NODE' + const fs = require('node:fs'); + const crypto = require('node:crypto'); + const { spawnSync } = require('node:child_process'); + const archive = 'npm-artifact/package.tgz'; + const sha = /^[0-9a-f]{40}$/; + if (!sha.test(process.env.SOURCE_SHA) || !sha.test(process.env.WORKFLOW_SHA) || + !/^\d+$/.test(process.env.GITHUB_RUN_ID) || !/^\d+$/.test(process.env.GITHUB_RUN_ATTEMPT) || + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(process.env.RELEASE_VERSION)) throw new Error('invalid receipt identity'); + const stat = fs.lstatSync(archive); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 500 * 1024 * 1024) throw new Error('invalid npm tarball'); + const entries = spawnSync('tar', ['-tzf', archive], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + if (entries.status !== 0 || entries.stdout.split('\n').filter(p => p === 'package/package.json').length !== 1) throw new Error('invalid npm manifest entry'); + const manifest = spawnSync('tar', ['-xOzf', archive, 'package/package.json'], { encoding: 'utf8', maxBuffer: 1024 * 1024 }); + if (manifest.status !== 0) throw new Error('cannot read npm manifest'); + const pkg = JSON.parse(manifest.stdout), trusted = JSON.parse(fs.readFileSync('package.json', 'utf8')); + if (pkg.name !== trusted.name || pkg.version !== process.env.RELEASE_VERSION || pkg.gitHead !== process.env.SOURCE_SHA) throw new Error('npm package identity or gitHead mismatch'); + const digest = crypto.createHash('sha256').update(fs.readFileSync(archive)).digest('hex'); + const receipt = { sourceSha: process.env.SOURCE_SHA, workflowSha: process.env.WORKFLOW_SHA, + version: process.env.RELEASE_VERSION, runId: process.env.GITHUB_RUN_ID, + attempt: process.env.GITHUB_RUN_ATTEMPT, packageName: pkg.name, gitHead: pkg.gitHead, sha256: digest }; + fs.writeFileSync('npm-artifact/receipt.json', JSON.stringify(receipt) + '\n'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `sha256=${digest}\n`); + NODE + + - name: Upload verified npm package and receipt + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-package-verified + path: npm-artifact/ + if-no-files-found: error + retention-days: 7 + # Pre-publication verification. Everything that will be published is checked # here — expected platform set, every checksum, the updater signatures, and the # manifest parse-back — and publication consumes this result rather than @@ -797,11 +1159,15 @@ jobs: permissions: contents: read env: + SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} + WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} UPDATER_SIGNING_CONFIGURED: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} steps: - - name: Checkout + - name: Checkout trusted workflow revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: + ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} persist-credentials: false - name: Setup project Bun @@ -823,23 +1189,49 @@ jobs: - name: Verify release assets env: - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} run: | set -euo pipefail args=( --version "$RELEASE_VERSION" --dir dist/release --repo "$GITHUB_REPOSITORY" - --sha "$GITHUB_SHA" + --sha "$SOURCE_SHA" --receipt-out verification/receipt.json ) - # Signatures are verified whenever they exist; the manifest is only - # generated when this run holds the updater key, exactly as before. + # HEAD release policy requires updater signatures whenever the key is configured. if [ "$UPDATER_SIGNING_CONFIGURED" = "true" ]; then args+=(--manifest-out dist/release/latest.json --require-signatures) fi bun desktop/scripts/verify-release-assets.ts "${args[@]}" + - name: Bind verified asset digests + run: | + set -euo pipefail + node - <<'NODE' + const fs = require('node:fs'), crypto = require('node:crypto'), path = require('node:path'); + (async () => { + const receipt = JSON.parse(fs.readFileSync('verification/receipt.json', 'utf8')); + if (receipt.version !== process.env.RELEASE_VERSION || receipt.sha !== process.env.SOURCE_SHA || receipt.repo !== process.env.GITHUB_REPOSITORY) throw Error('asset verifier identity mismatch'); + const names = fs.readdirSync('dist/release').sort(); + if (!names.length) throw Error('empty verified release'); + const assets = {}; + for (const name of names) { + if (name === '.' || name === '..' || name.includes('/') || name.includes('\\')) throw Error('invalid asset name'); + const file = path.join('dist/release', name), stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) throw Error('asset is not a regular file'); + const hash = crypto.createHash('sha256'); + for await (const chunk of fs.createReadStream(file)) hash.update(chunk); + assets[name] = hash.digest('hex'); + } + receipt.workflowSha = process.env.WORKFLOW_SHA; + receipt.runId = process.env.GITHUB_RUN_ID; + receipt.attempt = process.env.GITHUB_RUN_ATTEMPT; + receipt.assets = assets; + fs.writeFileSync('verification/receipt.json', JSON.stringify(receipt) + '\n'); + })().catch(error => { console.error(error.message); process.exitCode = 1; }); + NODE + - name: Upload verified release bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -858,15 +1250,20 @@ jobs: attach-release: runs-on: ubuntu-latest - needs: [publish, verify-release] + needs: [validate-dispatch, publish, verify-release] if: ${{ inputs.dry-run != true }} timeout-minutes: 10 permissions: contents: write + env: + SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} + WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: + ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} persist-credentials: false - name: Setup project Bun @@ -888,30 +1285,90 @@ jobs: # run's version and commit, or nothing uploads. - name: Require the verification receipt for this commit env: - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} run: | set -euo pipefail - receipt_version="$(bun -e 'console.log(JSON.parse(await Bun.file("verification/receipt.json").text()).version)')" - receipt_sha="$(bun -e 'console.log(JSON.parse(await Bun.file("verification/receipt.json").text()).sha)')" - test "$receipt_version" = "$RELEASE_VERSION" || { - echo "::error::verification receipt names version $receipt_version, not $RELEASE_VERSION" - exit 1 - } - test "$receipt_sha" = "$GITHUB_SHA" || { - echo "::error::verification receipt names commit $receipt_sha, not $GITHUB_SHA" - exit 1 - } + node - <<'NODE' + const fs = require('node:fs'), crypto = require('node:crypto'), path = require('node:path'); + (async () => { + const receipt = JSON.parse(fs.readFileSync('verification/receipt.json', 'utf8')); + if (receipt.version !== process.env.RELEASE_VERSION || receipt.sha !== process.env.SOURCE_SHA || + receipt.repo !== process.env.GITHUB_REPOSITORY || receipt.workflowSha !== process.env.WORKFLOW_SHA || + receipt.runId !== process.env.GITHUB_RUN_ID || !/^[1-9]\d*$/.test(receipt.attempt || '') || + !/^[1-9]\d*$/.test(process.env.GITHUB_RUN_ATTEMPT || '') || + BigInt(receipt.attempt) > BigInt(process.env.GITHUB_RUN_ATTEMPT) || + !receipt.assets || typeof receipt.assets !== 'object' || Array.isArray(receipt.assets)) throw Error('release receipt identity mismatch'); + const names = fs.readdirSync('dist/release').sort(), recorded = Object.keys(receipt.assets).sort(); + if (!names.length || JSON.stringify(names) !== JSON.stringify(recorded)) throw Error('release asset set mismatch'); + for (const name of names) { + if (name === '.' || name === '..' || /[\x00-\x1f\x7f/\\]/.test(name) || !/^[0-9a-f]{64}$/.test(receipt.assets[name])) throw Error('invalid asset receipt'); + const file = path.join('dist/release', name), stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) throw Error('asset is not a regular file'); + const hash = crypto.createHash('sha256'); + for await (const chunk of fs.createReadStream(file)) hash.update(chunk); + if (hash.digest('hex') !== receipt.assets[name]) throw Error(`asset sha256 mismatch: ${name}`); + } + })().catch(error => { console.error(error.message); process.exitCode = 1; }); + NODE + + - name: Check resume assets + if: ${{ inputs.resume-after-npm-publish == true }} + env: + GH_TOKEN: ${{ github.token }} + NPM_DIST_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + case "$NPM_DIST_TAG" in latest) channel=stable ;; preview) channel=preview ;; *) exit 1 ;; esac + decision="$(node .github/scripts/release-resume.cjs check-assets "$RELEASE_VERSION" "$SOURCE_SHA" "$channel" verification/receipt.json)" + DECISION="$decision" CHANNEL="$channel" node - <<'NODE' + const result = JSON.parse(process.env.DECISION); + if (result.version !== process.env.RELEASE_VERSION || result.sourceSha !== process.env.SOURCE_SHA || + result.channel !== process.env.CHANNEL || result.publishNpm !== false || result.assetsChecked !== true || + !Array.isArray(result.reuseAssets) || !Array.isArray(result.missingAssets)) throw Error('invalid resume asset decision'); + NODE - name: Attach to the release env: GH_TOKEN: ${{ github.token }} # Workflow inputs reach shell code through env, never by interpolation into # run: source. tests/ci-workflows.test.ts enforces this repo-wide. - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} run: | set -euo pipefail release_tag="v${RELEASE_VERSION}" - gh release upload "$release_tag" dist/release/* --clobber + # The preceding receipt check binds this local set to verified bytes. + # Existing assets are reused only when GitHub reports their exact digest. + checker="$RUNNER_TEMP/check-release-assets.cjs" + cat > "$checker" <<'NODE' + const fs = require('node:fs'); + const release = JSON.parse(process.env.RELEASE_INFO); + const receipt = JSON.parse(fs.readFileSync('verification/receipt.json', 'utf8')); + if (typeof release.isDraft !== 'boolean' || !Array.isArray(release.assets) || + !receipt.assets || typeof receipt.assets !== 'object' || Array.isArray(receipt.assets)) throw Error('invalid release or receipt'); + const expected = receipt.assets, names = Object.keys(expected), seen = new Set(); + if (!names.length) throw Error('empty asset receipt'); + for (const name of names) { + if (!name || name === '.' || name === '..' || /[\x00-\x1f\x7f/\\]/.test(name) || !/^[0-9a-f]{64}$/.test(expected[name])) throw Error('invalid asset receipt'); + } + for (const asset of release.assets) { + if (!asset || typeof asset.name !== 'string' || seen.has(asset.name) || !Object.hasOwn(expected, asset.name) || + asset.state !== 'uploaded' || asset.digest !== `sha256:${expected[asset.name]}`) throw Error('existing asset is unexpected, unavailable or has a different digest'); + seen.add(asset.name); + } + const missing = names.filter(name => !seen.has(name)); + if ((!release.isDraft || process.env.CHECK_FINAL === 'true') && missing.length) throw Error('release is missing verified assets'); + if (missing.length) process.stdout.write(missing.join('\0') + '\0'); + NODE + release_info="$(gh release view "$release_tag" --json isDraft,assets)" + RELEASE_INFO="$release_info" CHECK_FINAL=false node "$checker" > "$RUNNER_TEMP/missing-release-assets" + missing=() + while IFS= read -r -d '' name; do missing+=("dist/release/$name"); done < "$RUNNER_TEMP/missing-release-assets" + if [ "${#missing[@]}" -gt 0 ]; then + gh release upload "$release_tag" "${missing[@]}" + fi + # Re-read the entire remote set before publishing the draft. + release_info="$(gh release view "$release_tag" --json isDraft,assets)" + RELEASE_INFO="$release_info" CHECK_FINAL=true node "$checker" > /dev/null # A published release is immutable: GitHub rejects every later asset upload # with HTTP 422, which is why v2.55.0 through v2.60.0 shipped with zero @@ -940,12 +1397,16 @@ jobs: # release is invisible to it and reads as not public, which is the question the row answers. release-outcomes: name: release outcomes - needs: [publish, attach-release] - if: ${{ always() && inputs.dry-run != true }} + needs: [validate-dispatch, publish, attach-release] + if: ${{ always() && needs.validate-dispatch.result == 'success' && inputs.dry-run != true }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read + env: + SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} + WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -955,7 +1416,7 @@ jobs: - name: Report release outcomes env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} NPM_DIST_TAG: ${{ inputs.tag }} NPM_VERSION_STATE: ${{ needs.publish.outputs.npm_version }} NPM_DIST_TAG_STATE: ${{ needs.publish.outputs.npm_dist_tag }} @@ -964,7 +1425,8 @@ jobs: run: bash scripts/ci/release-outcome-report.sh publish: - needs: [validate-dispatch, verify-release] + needs: [validate-dispatch, verify-release, verify-npm] + if: ${{ inputs.dry-run != true }} runs-on: ubuntu-latest timeout-minutes: 15 outputs: @@ -975,318 +1437,267 @@ jobs: actions: read pull-requests: read id-token: write + env: + SOURCE_SHA: ${{ needs.validate-dispatch.outputs.source_sha }} + WORKFLOW_SHA: ${{ needs.validate-dispatch.outputs.workflow_sha }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} + # The ./ prefix is load-bearing: npm reads a bare "owner/repo..." spec as a GitHub shorthand + # and runs git ls-remote instead of publishing the file (run 42763073267). + NPM_TARBALL: ./npm-artifact/package.tgz + EXPECTED_DIGEST: ${{ needs.verify-npm.outputs.sha256 }} steps: - - name: Checkout + - name: Checkout trusted workflow revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: + ref: ${{ needs.validate-dispatch.outputs.workflow_sha }} fetch-depth: 0 - - name: Verify dispatched SHA - env: - EXPECTED_SHA: ${{ inputs.expected-sha }} + - name: Resolve trusted Bun version + id: bun-version run: | - if [ -z "$EXPECTED_SHA" ]; then - echo "::error::expected-sha is required; refusing to publish without an audited commit" - exit 1 - elif [ "$GITHUB_SHA" != "$EXPECTED_SHA" ]; then - echo "::error::branch moved after the release audit (expected ${EXPECTED_SHA}, got ${GITHUB_SHA}) — refusing to publish an unaudited commit" - exit 1 - fi + # JavaScript template literals are evaluated by Node, not the shell. + # shellcheck disable=SC2016 + node -e 'const v = require("./package.json").dependencies.bun; if (!/^\d+\.\d+\.\d+$/.test(v)) throw Error("invalid Bun pin"); require("node:fs").appendFileSync(process.env.GITHUB_OUTPUT, `version=${v}\n`);' - # opencodex is bun-native (the prepublishOnly audit, GUI build, and typecheck run under bun). - - name: Setup project Bun - uses: ./.github/actions/setup-project-bun + - name: Setup trusted Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ steps.bun-version.outputs.version }} - # node + npm perform the actual publish. registry-url points npm at the public registry. - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 - registry-url: "https://registry.npmjs.org" + registry-url: https://registry.npmjs.org - # Trusted Publishing (OIDC) needs npm >= 11.5.1. Node 24 runners already - # provide a compatible npm; avoid replacing the bundled npm because global - # npm self-updates can lose publish-time dependencies such as sigstore. - - name: Verify npm version - run: | - npm_version="$(npm --version)" - echo "npm=${npm_version}" - # shellcheck disable=SC2016 # the node script deliberately avoids shell expansion - node -e ' - const [major, minor] = process.argv[1].split(".").map(Number); - if (major < 11 || (major === 11 && minor < 5)) { - console.error(`npm ${process.argv[1]} is too old for trusted publishing; need >= 11.5.1`); - process.exit(1); - } - ' "$npm_version" + - name: Download verified npm package by immutable artifact ID + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.verify-npm.outputs.artifact_id }} + path: npm-artifact - - name: Install dependencies - run: bun install --frozen-lockfile + # Fetch the independently verified release receipt before publication, so + # an unavailable handoff cannot strand a newly published npm version. + - name: Download verified release receipt for publication + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-verification-receipt + path: verification - - name: Dependency audit (high severity) - run: bun run audit:high + - name: Verify immutable npm receipt + run: | + set -euo pipefail + node - <<'NODE' + const fs = require('node:fs'); + const crypto = require('node:crypto'); + const { spawnSync } = require('node:child_process'); + const archive = process.env.NPM_TARBALL; + const stat = fs.lstatSync(archive); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 500 * 1024 * 1024) throw new Error('invalid npm tarball'); + const receipt = JSON.parse(fs.readFileSync('npm-artifact/receipt.json', 'utf8')); + const sha256 = crypto.createHash('sha256').update(fs.readFileSync(archive)).digest('hex'); + const expectedName = JSON.parse(fs.readFileSync('package.json', 'utf8')).name; + if (!/^[0-9a-f]{64}$/.test(process.env.EXPECTED_DIGEST) || sha256 !== process.env.EXPECTED_DIGEST || + receipt.sha256 !== sha256 || receipt.sourceSha !== process.env.SOURCE_SHA || receipt.workflowSha !== process.env.WORKFLOW_SHA || + receipt.version !== process.env.RELEASE_VERSION || receipt.runId !== process.env.GITHUB_RUN_ID || + !/^[1-9]\d*$/.test(receipt.attempt || '') || !/^[1-9]\d*$/.test(process.env.GITHUB_RUN_ATTEMPT || '') || + BigInt(receipt.attempt) > BigInt(process.env.GITHUB_RUN_ATTEMPT) || receipt.packageName !== expectedName || + receipt.gitHead !== process.env.SOURCE_SHA) throw new Error('npm receipt identity or digest mismatch'); + const entries = spawnSync('tar', ['-tzf', archive], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + if (entries.status !== 0 || entries.stdout.split('\n').filter(p => p === 'package/package.json').length !== 1) throw new Error('invalid npm manifest entry'); + const manifest = spawnSync('tar', ['-xOzf', archive, 'package/package.json'], { encoding: 'utf8', maxBuffer: 1024 * 1024 }); + if (manifest.status !== 0) throw new Error('cannot read npm manifest'); + const pkg = JSON.parse(manifest.stdout); + if (pkg.name !== expectedName || pkg.version !== process.env.RELEASE_VERSION || pkg.gitHead !== process.env.SOURCE_SHA || pkg.gitHead !== receipt.gitHead) throw new Error('npm package identity or gitHead mismatch'); + NODE - - name: Verify every version source matches the requested version + # Prepare and validate the receipt before the irreversible npm publication. + - name: Prepare publication receipt + if: ${{ inputs.resume-after-npm-publish != true }} env: - RELEASE_VERSION: ${{ inputs.version }} + NPM_DIST_TAG: ${{ inputs.tag }} run: | - # package.json plus the desktop sources (tauri.conf.json, Cargo.toml and the - # opencodex-desktop Cargo.lock entry). package-desktop already refused to build on a - # mismatch; this re-proves it on the commit that is about to publish. - bun scripts/release-version-sources.ts check "$RELEASE_VERSION" || { - echo "::error::a version source != requested (${RELEASE_VERSION}) — run scripts/release.ts, which moves all of them, on main first"; - exit 1; + set -euo pipefail + mkdir -p publication + node - <<'NODE' + const fs = require('node:fs'); + const receipt = JSON.parse(fs.readFileSync('verification/receipt.json', 'utf8')); + const sha = /^[0-9a-f]{40}$/, digest = /^[0-9a-f]{64}$/; + const runId = Number(process.env.GITHUB_RUN_ID), attempt = Number(process.env.GITHUB_RUN_ATTEMPT); + const version = process.env.RELEASE_VERSION, tag = process.env.NPM_DIST_TAG; + if (!sha.test(process.env.SOURCE_SHA || '') || !sha.test(process.env.WORKFLOW_SHA || '') || + !/^[1-9]\d*$/.test(process.env.GITHUB_RUN_ID || '') || !/^[1-9]\d*$/.test(process.env.GITHUB_RUN_ATTEMPT || '') || + !digest.test(process.env.EXPECTED_DIGEST || '') || !Number.isSafeInteger(runId) || runId <= 0 || + !Number.isSafeInteger(attempt) || attempt <= 0 || !/^\d+\.\d+\.\d+(?:-preview\.\d{8}\.\d+)?$/.test(version || '') || + !((tag === 'latest' && !version.includes('-')) || (tag === 'preview' && version.includes('-preview.')))) throw Error('invalid publication identity'); + if (receipt.sha !== process.env.SOURCE_SHA || receipt.workflowSha !== process.env.WORKFLOW_SHA || + receipt.version !== version || receipt.repo !== process.env.GITHUB_REPOSITORY || + receipt.runId !== process.env.GITHUB_RUN_ID || !/^[1-9]\d*$/.test(receipt.attempt || '') || + BigInt(receipt.attempt) > BigInt(process.env.GITHUB_RUN_ATTEMPT) || + !receipt.assets || typeof receipt.assets !== 'object' || Array.isArray(receipt.assets) || !Object.keys(receipt.assets).length) throw Error('release asset receipt mismatch'); + for (const [name, value] of Object.entries(receipt.assets)) { + if (!name || /[\x00-\x1f/\\]/.test(name) || !digest.test(value)) throw Error('invalid release asset receipt'); } + const publication = { sourceSha: process.env.SOURCE_SHA, workflowSha: process.env.WORKFLOW_SHA, + version, channel: tag === 'latest' ? 'stable' : 'preview', sha256: process.env.EXPECTED_DIGEST, + gitHead: process.env.SOURCE_SHA, runId, attempt, published: true, assets: receipt.assets }; + fs.writeFileSync('publication/pending.json', JSON.stringify(publication) + '\n', { flag: 'wx' }); + NODE - # The exact-SHA CI gate includes the hosted Linux, Windows, and macOS - # keyring smoke matrix. Do not duplicate its Linux bootstrap here. - - name: Require successful Cross-platform CI for this commit + - name: Recheck source CI and service evidence env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ inputs.version }} - NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail - - case "$GITHUB_REF" in - refs/heads/main) - expected_tag="latest" - if [[ "$RELEASE_VERSION" == *-* ]]; then - echo "::error::main releases must use a stable semver version; got ${RELEASE_VERSION}" - exit 1 - fi - ;; - refs/heads/preview) - expected_tag="preview" - if [[ "$RELEASE_VERSION" != *-preview.* ]]; then - echo "::error::preview releases must use a preview prerelease version; got ${RELEASE_VERSION}" - exit 1 - fi - ;; - *) - echo "::error::Release must run from main or preview; got ${GITHUB_REF}" - exit 1 - ;; - esac - - if [ "$NPM_DIST_TAG" != "$expected_tag" ]; then - echo "::error::${GITHUB_REF#refs/heads/} releases must publish with npm dist-tag '${expected_tag}', got '${NPM_DIST_TAG}'" - exit 1 - fi - - ci_url="$( - gh run list \ - --workflow ci.yml \ - --branch "${GITHUB_REF#refs/heads/}" \ - --commit "$GITHUB_SHA" \ - --event push \ - --limit 10 \ - --json conclusion,url \ - --jq '[.[] | select(.conclusion == "success")][0].url // ""' - )" - - if [ -z "$ci_url" ]; then - # Deliberately narrower than "any successful ci.yml run for this SHA". - # The release branch's own push run is the one whose trigger set, - # runner selection, and job graph match what is being published — - # a green PR run for the same SHA ran against a merge ref with a - # different trigger context. Accepting it here would let a publish - # proceed while the promotion run was still pending, or had failed. - echo "::error::No successful Cross-platform CI run found for ${GITHUB_SHA} on ${GITHUB_REF#refs/heads/} (push event). A pull-request run does not qualify. Wait for the promotion run to pass before releasing." - gh run list --workflow ci.yml --commit "$GITHUB_SHA" --limit 10 || true - exit 1 + node .github/scripts/release-evidence.cjs check "$SOURCE_SHA" + baseline_tag="$(git tag --merged "$SOURCE_SHA" --list 'v[0-9]*' | bun -e ' + import { parseVersion, compareVersions } from "./scripts/version-line.ts"; + const tags = (await Bun.stdin.text()).trim().split("\n").filter(Boolean); + const versions = tags.map(tag => ({ tag, version: tag.slice(1) })); + if (versions.some(item => !parseVersion(item.version))) throw new Error("invalid release tag"); + const eligible = versions.filter(item => compareVersions(item.version, process.env.RELEASE_VERSION) < 0); + eligible.sort((a, b) => compareVersions(a.version, b.version)); + console.log(eligible.at(-1)?.tag ?? ""); + ')" + baseline=--initial + if [ -n "$baseline_tag" ]; then + draft="$(gh release view "$baseline_tag" --json isDraft --jq .isDraft)" + test "$draft" = false || { echo '::error::Service baseline is not a published release.'; exit 1; } + baseline="$(git rev-parse "refs/tags/${baseline_tag}^{commit}")" fi - - echo "Cross-platform CI passed for ${GITHUB_SHA}: ${ci_url}" - - # Service baseline (lineage-relative): merged tags only, so the - # changed-files gate compares against the last release actually - # reachable from this commit. The release-notes baseline below uses the - # full tag set instead, so a stable on another lineage can anchor the - # changelog range. - # - Preview: newest prior release of either channel (stable or preview). A - # preview→preview-only baseline skips a shipped stable and restates it. - # - Stable: newest prior stable only (matching preview carry adjusts the - # generate-notes range separately below). - previous_tag="$( - git tag --merged HEAD --list 'v[0-9]*' | - bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION" - )" - if [ -n "$previous_tag" ]; then - changed_files="$(git diff --name-only "${previous_tag}..HEAD")" - else - changed_files="$(git diff-tree --no-commit-id --name-only -r "$GITHUB_SHA")" - fi - - # Keep in sync with the service-lifecycle.yml trigger paths. src/cli.ts is - # the pre-restructure compat stub that durable launchers still execute; the - # service implementation itself is the src/service/ directory, and the desktop - # shell packages and launches it. - if printf '%s\n' "$changed_files" | grep -Eq '^(src/service\.ts|src/service/.*|desktop/.*|src/cli\.ts|src/cli/index\.ts|src/lib/bun-runtime\.ts|package\.json|bun\.lock|\.github/workflows/service-lifecycle\.yml|\.github/workflows/release\.yml)$'; then - service_url="$( - gh run list \ - --workflow service-lifecycle.yml \ - --commit "$GITHUB_SHA" \ - --limit 10 \ - --json conclusion,headSha,url,workflowName \ - --jq '[.[] | select(.conclusion == "success")][0].url // ""' - )" - if [ -z "$service_url" ]; then - echo "::error::Service-related files changed since ${previous_tag:-initial commit}, but no successful Service lifecycle run was found for ${GITHUB_SHA}." - gh run list --workflow service-lifecycle.yml --commit "$GITHUB_SHA" --limit 10 || true - exit 1 - fi - echo "Service lifecycle passed for ${GITHUB_SHA}: ${service_url}" + range="$(node .github/scripts/release-evidence.cjs service-range "$SOURCE_SHA" "$baseline")" + required="$(RANGE="$range" node -e 'const x = JSON.parse(process.env.RANGE); if (typeof x.required !== "boolean") throw Error("invalid service range"); process.stdout.write(String(x.required));')" + if [ "$required" = true ]; then + node .github/scripts/release-evidence.cjs service "$SOURCE_SHA" || { + echo "::error::Dispatch service-lifecycle.yml from dev for source ${SOURCE_SHA} and wait for all platforms."; exit 1; + } fi - - name: Require dev to be ready for this release - env: - RELEASE_VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - git fetch --force --tags origin +refs/heads/dev:refs/remotes/origin/dev - dev_version="$(git show origin/dev:package.json | bun -e 'console.log(JSON.parse(await Bun.stdin.text()).version)')" - bun scripts/version-line.ts assert-ahead "$dev_version" "$RELEASE_VERSION" - - # Tokenless publish via Trusted Publishing (OIDC) — NO NPM_TOKEN secret. npm auto-detects the - # OIDC environment (`id-token: write` above) and generates provenance automatically, so neither a - # token nor `--provenance` is needed. `npm publish` runs prepublishOnly first (typecheck + build - # the GUI into gui/dist), so even a dry-run fully verifies the build. - # PREREQUISITE: configure the Trusted Publisher for this repo + workflow on npmjs.com — possible - # only AFTER the package's first version exists (do the first publish locally, see the runbook). - - name: Preflight release metadata - id: metadata + - name: Refuse a release the current tag set already outranks env: - GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ inputs.version }} - DRY_RUN: ${{ inputs.dry-run }} RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail - - pkg_name="$(node -p "require('./package.json').name")" - release_tag="v${RELEASE_VERSION}" - dry_run="$DRY_RUN" - git fetch --force --tags origin - - existing_tag_sha="$(git rev-parse -q --verify "refs/tags/${release_tag}^{commit}" || true)" - if [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" != "$GITHUB_SHA" ]; then - echo "::error::${release_tag} already points at ${existing_tag_sha}, not ${GITHUB_SHA}" - exit 1 - fi - - if [ -n "$existing_tag_sha" ]; then - if [ "$RESUME" = "true" ]; then - echo "::notice::${release_tag} already exists at this commit; resuming" - elif [ "$dry_run" = "true" ]; then - echo "::notice::${release_tag} already exists at this commit; dry-run only" - else - echo "::error::${release_tag} already exists. Refusing to publish a version with pre-existing Git metadata." - exit 1 - fi + allow="" + existing_tag_sha="$(git rev-parse -q --verify "refs/tags/v${RELEASE_VERSION}^{commit}" || true)" + if [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" != "$SOURCE_SHA" ]; then + echo '::error::Existing release tag points at a different source.'; exit 1 fi + if [ "$RESUME" = true ] && [ -n "$existing_tag_sha" ]; then allow="--allow-existing-tag-at-head"; fi + git tag --list 'v*' | bun scripts/version-line.ts assert-releasable "$RELEASE_VERSION" ${allow:+"$allow"} - if gh release view "$release_tag" >/dev/null 2>&1; then - if [ "$RESUME" = "true" ]; then - echo "::notice::GitHub Release ${release_tag} already exists; resuming to complete the attachment" - elif [ "$dry_run" = "true" ]; then - echo "::notice::GitHub Release ${release_tag} already exists; dry-run only" - else - echo "::error::GitHub Release ${release_tag} already exists. Choose the next unused patch version." - exit 1 - fi - fi + - name: Build and validate release changelog + env: + GH_TOKEN: ${{ github.token }} + NPM_DIST_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + notes_file="$GITHUB_WORKSPACE/.release-notes.md" + bun scripts/build-release-changelog.ts --version "$RELEASE_VERSION" --dist-tag "$NPM_DIST_TAG" \ + --repository "$GITHUB_REPOSITORY" --target "$SOURCE_SHA" --out "$notes_file" + test -s "$notes_file" - if [ "$RESUME" = "true" ] && [ "$dry_run" = "true" ]; then - echo "::error::resume-after-npm-publish is a real-publication recovery path and cannot combine with dry-run" - exit 1 - fi - if npm view "${pkg_name}@${RELEASE_VERSION}" version >/dev/null 2>&1; then - if [ "$RESUME" = "true" ]; then - resume_git_head="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" gitHead --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000)" || { - echo "::error::Cannot verify the existing npm package source; resume refused" - exit 1 - } - bun scripts/verify-release-resume.ts "$GITHUB_SHA" "$resume_git_head" - echo "resume_sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT" - echo "::notice::${pkg_name}@${RELEASE_VERSION} is acknowledged on npm; resuming after the recorded partial publication" - elif [ "$dry_run" = "true" ]; then - echo "::notice::${pkg_name}@${RELEASE_VERSION} already exists on npm; dry-run only" - else - echo "::error::${pkg_name}@${RELEASE_VERSION} already exists on npm. If a previous run acknowledged this publication and failed afterwards, re-dispatch with resume-after-npm-publish: true; otherwise choose the next unused patch version." - exit 1 - fi - elif [ "$RESUME" = "true" ]; then - echo "::error::resume-after-npm-publish is set, but ${pkg_name}@${RELEASE_VERSION} is not on npm — there is no acknowledged publication to resume from" + - name: Require publication hold to be enabled + env: + DRY_RUN: ${{ inputs.dry-run }} + PUBLISH_ENABLED: ${{ vars.OCX_DEV_RELEASE_PUBLISH_ENABLED }} + run: | + if [ "$DRY_RUN" != "true" ] && [ "$PUBLISH_ENABLED" != "true" ]; then + echo '::error::Publication is disabled; OCX_DEV_RELEASE_PUBLISH_ENABLED must equal true.' exit 1 fi - - name: Refuse a release the current tag set already outranks + - name: Require reconciled publication history + if: ${{ inputs.resume-after-npm-publish != true }} env: - RELEASE_VERSION: ${{ inputs.version }} - DRY_RUN: ${{ inputs.dry-run }} - RESUME: ${{ inputs.resume-after-npm-publish }} + GH_TOKEN: ${{ github.token }} + NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail - allow="" - existing_tag_sha="$(git rev-parse -q --verify "refs/tags/v${RELEASE_VERSION}^{commit}" || true)" - # Dry-run re-dispatches and the resume path both legitimately find the tag - # already at this commit; a moved tag is still refused above. - if { [ "$DRY_RUN" = "true" ] || [ "$RESUME" = "true" ]; } && [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" = "$GITHUB_SHA" ]; then - allow="--allow-existing-tag-at-head" + if ! [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || [ -z "$RELEASE_VERSION" ]; then + echo '::error::Invalid release identity for history reconciliation.'; exit 1 fi - git tag --list 'v*' | bun scripts/version-line.ts assert-releasable "$RELEASE_VERSION" $allow + case "$NPM_DIST_TAG" in + latest) channel=stable ;; + preview) channel=preview ;; + *) echo '::error::Invalid release channel.'; exit 1 ;; + esac + node .github/scripts/release-history.cjs check "$SOURCE_SHA" "$RELEASE_VERSION" "$channel" - - name: Build and validate release changelog + - name: Verify resume registry identity + id: resume-identity + if: ${{ inputs.resume-after-npm-publish == true }} + run: | + set -euo pipefail + pkg_name="$(node -p "require('./package.json').name")" + registry_version="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" version --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000)" || { + echo '::error::Cannot read the exact npm version; resume refused.'; exit 1; + } + REGISTRY_VERSION="$registry_version" node - <<'NODE' + const value = JSON.parse(process.env.REGISTRY_VERSION); + if (typeof value !== 'string' || value !== process.env.RELEASE_VERSION) throw Error('npm version does not match original release'); + NODE + registry_head="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" gitHead --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000)" || { + echo '::error::Cannot read npm gitHead; resume refused.'; exit 1; + } + bun scripts/verify-release-resume.ts "$SOURCE_SHA" "$registry_head" + echo "resume_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + + - name: Check resume publication + id: resume-check + if: ${{ inputs.resume-after-npm-publish == true }} env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ inputs.version }} NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail - notes_file="$GITHUB_WORKSPACE/.release-notes.md" - bun scripts/build-release-changelog.ts \ - --version "$RELEASE_VERSION" \ - --dist-tag "$NPM_DIST_TAG" \ - --repository "$GITHUB_REPOSITORY" \ - --target "$GITHUB_SHA" \ - --out "$notes_file" - test -s "$notes_file" || { - echo "::error::release changelog builder produced an empty notes file" - exit 1 - } + case "$NPM_DIST_TAG" in latest) channel=stable ;; preview) channel=preview ;; *) exit 1 ;; esac + decision="$(node .github/scripts/release-resume.cjs check "$RELEASE_VERSION" "$SOURCE_SHA" "$channel")" + DECISION="$decision" CHANNEL="$channel" node - <<'NODE' + const fs = require('node:fs'), result = JSON.parse(process.env.DECISION); + if (result.version !== process.env.RELEASE_VERSION || result.sourceSha !== process.env.SOURCE_SHA || + result.channel !== process.env.CHANNEL || result.publishNpm !== false || result.assetsChecked !== false) throw Error('invalid resume publication decision'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `resume_checked_sha=${result.sourceSha}\n`); + NODE - - name: Publish (or dry-run) + - name: Publish verified npm tarball id: publication env: - DRY_RUN: ${{ inputs.dry-run }} NPM_DIST_TAG: ${{ inputs.tag }} RESUME: ${{ inputs.resume-after-npm-publish }} - RELEASE_VERSION: ${{ inputs.version }} - VERIFIED_RESUME_SHA: ${{ steps.metadata.outputs.resume_sha }} + VERIFIED_RESUME_SHA: ${{ steps.resume-identity.outputs.resume_sha }} + VERIFIED_RESUME_DECISION: ${{ steps.resume-check.outputs.resume_checked_sha }} run: | set -euo pipefail - pkg_name="$(node -p "require('./package.json').name")" - if [ "$RESUME" = "true" ]; then - if [ -z "$VERIFIED_RESUME_SHA" ] || [ "$VERIFIED_RESUME_SHA" != "$GITHUB_SHA" ]; then - echo "::error::Resume has no matching registry source verification; publication remains unacknowledged" - exit 1 + if [ "$RESUME" = true ]; then + if [ -z "$VERIFIED_RESUME_SHA" ] || [ "$VERIFIED_RESUME_SHA" != "$SOURCE_SHA" ] || + [ -z "$VERIFIED_RESUME_DECISION" ] || [ "$VERIFIED_RESUME_DECISION" != "$SOURCE_SHA" ]; then + echo '::error::Resume has no matching registry verification; refusing publication acknowledgement.'; exit 1 fi - # npm publication was acknowledged by the earlier run and confirmed by the - # preflight above; completing the GitHub side must never republish. - echo "::notice::RESUME — npm publish skipped; publication already acknowledged" - echo "published=true" >> "$GITHUB_OUTPUT" - echo "Publication resumed for ${pkg_name}@${RELEASE_VERSION} at ${GITHUB_SHA} (npm publish skipped; acknowledged by the earlier run)." >> "$GITHUB_STEP_SUMMARY" - elif [ "$DRY_RUN" = "true" ]; then - echo "::notice::DRY RUN — building + packing, not publishing" - npm run prepublishOnly - npm pack --dry-run + echo "::notice::Resume verified; npm publish skipped." else - npm publish --tag "$NPM_DIST_TAG" --access public - echo "published=true" >> "$GITHUB_OUTPUT" - echo "Publication acknowledged for ${pkg_name}@${RELEASE_VERSION} at ${GITHUB_SHA}. If any later step in this run fails, re-dispatch with the same version and expected-sha plus resume-after-npm-publish: true — never republish this version." >> "$GITHUB_STEP_SUMMARY" + npm publish "$NPM_TARBALL" --ignore-scripts --tag "$NPM_DIST_TAG" --access public fi + echo "published=true" >> "$GITHUB_OUTPUT" + echo "Publication acknowledged for ${RELEASE_VERSION} at ${SOURCE_SHA}; do not republish this version." >> "$GITHUB_STEP_SUMMARY" + + # A same-run artifact is evidence to be authenticated, not provenance by itself. + - name: Record acknowledged publication + if: ${{ steps.publication.outputs.published == 'true' && inputs.resume-after-npm-publish != true }} + run: | + set -euo pipefail + test -s publication/pending.json && test ! -e publication/receipt.json + mv publication/pending.json publication/receipt.json + + - name: Upload publication receipt + if: ${{ steps.publication.outputs.published == 'true' && inputs.resume-after-npm-publish != true }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-publication-receipt + path: publication/receipt.json + if-no-files-found: error + retention-days: 7 # Publication is acknowledged before registry reads, which can lag or fail. # Recover only observation failures in this run; never retry npm publish. @@ -1294,7 +1705,7 @@ jobs: id: registry-smoke if: ${{ inputs.dry-run != true && steps.publication.outputs.published == 'true' }} env: - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} NPM_DIST_TAG: ${{ inputs.tag }} PUBLISHED: ${{ steps.publication.outputs.published }} run: | @@ -1338,17 +1749,41 @@ jobs: echo "::notice::Registry lookup not confirmed (attempt $attempt/6)" if [ "$attempt" -lt 6 ]; then sleep 5; fi done + # Keep each outcome explicit for the recovery report. + # shellcheck disable=SC2129 echo "verification=pending" >> "$GITHUB_OUTPUT" echo "npm_version=unconfirmed" >> "$GITHUB_OUTPUT" echo "npm_dist_tag=unconfirmed" >> "$GITHUB_OUTPUT" echo "::warning::npm publish succeeded, but registry verification remains pending; continuing GitHub release creation without republishing" echo "Publication acknowledged for ${pkg_name}@${RELEASE_VERSION}; registry verification pending after bounded reads. Inspect the registry before announcing availability. Do not republish this version." >> "$GITHUB_STEP_SUMMARY" + - name: Verify published npm source + if: ${{ steps.publication.outputs.published == 'true' }} + run: | + set -euo pipefail + pkg_name="$(node -p "require('./package.json').name")" + for attempt in $(seq 1 6); do + if head="$(timeout --kill-after=2s 10s npm view "${pkg_name}@${RELEASE_VERSION}" gitHead --json --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=8000 2>/dev/null)"; then + if [ -n "$head" ] && [ "$head" != null ]; then + REGISTRY_HEAD="$head" node - <<'NODE' + const head = JSON.parse(process.env.REGISTRY_HEAD); + if (!/^[0-9a-f]{40}$/.test(process.env.SOURCE_SHA || '') || head !== process.env.SOURCE_SHA) { + throw Error('published npm gitHead does not match selected source'); + } + NODE + exit 0 + fi + fi + if [ "$attempt" -lt 6 ]; then sleep 5; fi + done + echo '::error::Published npm gitHead could not be verified; do not republish and reconcile manually.' + exit 1 + - name: Create GitHub release if: ${{ inputs.dry-run != true && steps.publication.outputs.published == 'true' }} env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ needs.validate-dispatch.outputs.version }} RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail @@ -1363,8 +1798,8 @@ jobs: git fetch --force --tags origin existing_tag_sha="$(git rev-parse -q --verify "refs/tags/${release_tag}^{commit}" || true)" - if [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" != "$GITHUB_SHA" ]; then - echo "::error::${release_tag} already points at ${existing_tag_sha}, not ${GITHUB_SHA}" + if [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" != "$SOURCE_SHA" ]; then + echo "::error::${release_tag} already points at ${existing_tag_sha}, not ${SOURCE_SHA}" exit 1 fi @@ -1374,7 +1809,7 @@ jobs: fi if [ -z "$existing_tag_sha" ]; then - git tag "$release_tag" "$GITHUB_SHA" + git tag "$release_tag" "$SOURCE_SHA" git push origin "refs/tags/${release_tag}" fi @@ -1393,6 +1828,6 @@ jobs: # Draft first. Publication freezes a release under GitHub's immutable # releases, so attach-release attaches the verified bundle to the draft # and publishes it afterwards. - gh release create "$release_tag" --draft --target "$GITHUB_SHA" --title "$release_tag" \ + gh release create "$release_tag" --draft --target "$SOURCE_SHA" --title "$release_tag" \ --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} fi diff --git a/.github/workflows/service-lifecycle.yml b/.github/workflows/service-lifecycle.yml index ad865033406..87249bf4d19 100644 --- a/.github/workflows/service-lifecycle.yml +++ b/.github/workflows/service-lifecycle.yml @@ -2,7 +2,7 @@ name: Service lifecycle on: pull_request: - branches: [main, dev] + branches: [dev] paths: - "src/service.ts" # The service implementation is the src/service/ directory; src/service.ts is only @@ -23,45 +23,62 @@ on: # commit that touches only release.yml (e.g. the v2.40.0 permissions carry, #3263/#3264) # produced no run and the gate dead-ended until a manual dispatch. - ".github/workflows/release.yml" - push: - # Release lines only (no dev: cross-platform legs run on dev by manual - # workflow_dispatch). Pull requests run only the Linux leg. main and preview - # must stay: release.yml needs a successful push run for the exact release - # SHA, and scripts/release.ts only releases from main or preview. Without a - # branch filter every feature-branch push would re-run all three legs. - branches: [main, preview] - paths: - - "src/service.ts" - # Keep in sync with the release.yml service-gate regex (see above). - - "src/service/**" - - "desktop/**" - # Keep in sync with the release.yml service-gate regex (see above). - - "src/cli.ts" - - "src/cli/index.ts" - - "src/lib/bun-runtime.ts" - - "package.json" - - "bun.lock" - - ".github/workflows/service-lifecycle.yml" - # release.yml gates on THIS workflow having run for the release SHA. A release-branch - # commit that touches only release.yml (e.g. the v2.40.0 permissions carry, #3263/#3264) - # produced no run and the gate dead-ended until a manual dispatch. - - ".github/workflows/release.yml" workflow_dispatch: + inputs: + source_sha: + description: "Full dev ancestor SHA to test" + required: true + type: string permissions: contents: read concurrency: - group: service-lifecycle-${{ github.ref }} - cancel-in-progress: true + group: service-lifecycle-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} jobs: + validate-source: + name: validate source + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Validate dispatch source + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + SOURCE_SHA: ${{ github.event.inputs.source_sha }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != workflow_dispatch ]; then exit 0; fi + if [ "$REF" != refs/heads/dev ] || ! [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo '::error::dispatch requires dev and a full lowercase source SHA' + exit 1 + fi + status="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${SOURCE_SHA}...dev" --jq .status)" + if [ "$status" != ahead ] && [ "$status" != identical ]; then + echo '::error::source is not an ancestor of dev' + exit 1 + fi + linux-systemd: + name: linux-systemd ${{ github.event.inputs.source_sha }} + needs: validate-source runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: ./.github/actions/setup-project-bun + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.source_sha || github.sha }} + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: .evidence-policy + ref: ${{ github.sha }} + persist-credentials: false + - uses: ./.evidence-policy/.github/actions/setup-project-bun - run: bun install --frozen-lockfile @@ -177,13 +194,23 @@ jobs: fi macos-launchd: - # Pull requests run the Linux leg only; macOS runs on manual dispatch and main/preview push. + name: macos-launchd ${{ github.event.inputs.source_sha }} + needs: validate-source + # Pull requests run the Linux leg only; macOS runs on manual dispatch. if: github.event_name != 'pull_request' runs-on: macos-latest timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: ./.github/actions/setup-project-bun + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.source_sha || github.sha }} + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: .evidence-policy + ref: ${{ github.sha }} + persist-credentials: false + - uses: ./.evidence-policy/.github/actions/setup-project-bun - run: bun install --frozen-lockfile @@ -257,13 +284,23 @@ jobs: fi windows-schtasks: - # Pull requests run the Linux leg only; Windows runs on manual dispatch and main/preview push. + name: windows-schtasks ${{ github.event.inputs.source_sha }} + needs: validate-source + # Pull requests run the Linux leg only; Windows runs on manual dispatch. if: github.event_name != 'pull_request' runs-on: windows-latest timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: ./.github/actions/setup-project-bun + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.source_sha || github.sha }} + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: .evidence-policy + ref: ${{ github.sha }} + persist-credentials: false + - uses: ./.evidence-policy/.github/actions/setup-project-bun - run: bun install --frozen-lockfile diff --git a/.github/workflows/stale-needs-info.yml b/.github/workflows/stale-needs-info.yml index d33fb204a51..ca9838ff21b 100644 --- a/.github/workflows/stale-needs-info.yml +++ b/.github/workflows/stale-needs-info.yml @@ -1,16 +1,14 @@ name: Close stale needs-info issues -# Scheduled workflows only run from the repository DEFAULT branch -# (currently `main`), not from `dev`. Landing here on `dev` alone does not -# change live issue-stale behavior until the change is also on that default -# branch. +# Scheduled workflows only run from the repository DEFAULT branch, which is +# `dev`. A change lands on the live schedule as soon as it is on `dev`. on: schedule: # Daily at 06:15 UTC (offset from the hour to reduce Action load spikes). - cron: "15 6 * * *" # No workflow_dispatch: a branch-selected manual run would execute that # branch's workflow body with issues:write / pull-requests:write, bypassing - # default-branch review. Schedule-only keeps the trusted revision on main. + # default-branch review. Schedule-only keeps the trusted revision on `dev`. permissions: issues: write diff --git a/AGENTS.md b/AGENTS.md index 272c882c688..a0f2f3da1f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -351,8 +351,8 @@ than nudged. they were deleted; `tests/ci-workflows/repo-hygiene.test.ts` now rejects those folders. When the PR resolves an issue, add `Closes #` to link it. GitHub auto-closes the linked issue only - when the PR merges into the default branch (`main`); PRs here target - `dev`, so close the issue manually once the change is on `dev`. + when the PR merges into the default branch, which is `dev`, so a linked issue + closes when its pull request merges. - **Landing another author's work:** reimplementing, superseding, carrying, or rebasing someone else's pull request requires a `Co-authored-by` trailer naming that author, in the description or in a branch commit so it survives @@ -374,7 +374,8 @@ than nudged. (`MAINTAINERS.md`, "Batched merges"). Because `test changed` follows the module graph, add `ci:full` for changes it cannot see (subprocesses, files read as data, generated output). - Full cross-platform (macOS/Windows) CI runs **only** via manual - `workflow_dispatch` and on push to `main`/`preview` (release lines). + `workflow_dispatch` on `dev`. A successful complete `lane=all` run establishes + evidence for its exact dev commit; a PR run or narrower manual lane does not. - A manual `lane=all` dispatch is a **batch check on `dev`**, never a per-PR gate. One run is about 32 jobs (11 Windows, at least 6 macOS, 15 Linux) and six runner-hours, and the account has only five macOS slots (a macOS job can queue), so per-PR dispatches starve @@ -383,10 +384,9 @@ than nudged. - Merge a pull request, including a Windows- or macOS-sensitive one, on its green PR CI. After a batch of merges, run one `gh workflow run ci.yml --ref dev` as described under "Batched merges". If it is red, stop merging and fix `dev` from that batch. - - Do not dispatch `lane=all` for a PR branch unless a maintainer explicitly asks for it - on that PR. Even then, run it once, on the exact head about to merge. Never re-dispatch - because the head moved (an `origin/dev` merge or a review fix), and never rerun to - green. Cancel your own superseded dispatches with `gh run cancel`. + - Do not dispatch `lane=all` for a PR branch. Run it once for a batch on `dev`; + never re-dispatch merely because the head moved or rerun to green. Cancel your + own superseded dispatches with `gh run cancel`. - Every description edit or label change starts three event-gate workflows. Batch those edits, and keep the number of agents working pull requests in parallel small. - **Never** add a `dev` push trigger, or `pull_request` runs for macOS/Windows @@ -398,10 +398,14 @@ In `donadiosolutions/opencodex`, fork-specific pull requests target `custom`; the fork's target gate also continues to accept `dev`. The upstream policy below is unchanged. -- `dev` — the upstream integration branch and target for its pull requests. -- `main` — release branch. It only moves by maintainer-controlled promotion - from `dev` (releases, docs deploys). Do not open feature PRs against `main`. -- `preview` — prerelease train (`x.y.z-preview.*` versions). +- `dev` — the single development and integration branch, the repository default + branch, and the target for every ordinary pull request. Preview and stable + releases are built from eligible commits on this line, with versions stamped in + build checkouts. +- `main` and `preview` were deleted once the single-branch model was in place. + Their released history remains reachable through tags; they are not + integration, promotion, or review targets, and no workflow may trigger on or + check out either branch. Bun-native TypeScript on `dev` is the only runtime line. If native code returns, the expectation is an incremental module (for example Rust via N-API) @@ -416,9 +420,9 @@ Rebase pull requests are welcome. Bringing a stale branch onto the current head is ordinary maintenance — open it as a normal pull request and name the source commits in the description. -The **`enforce-target`** CI check rejects pull requests whose head -ancestry sits on the **`main`** tip while far behind **`dev`**, and rejects -empty, thin, or malformed descriptions. If changed paths include files under +The **`enforce-target`** CI check rejects pull requests whose base is not +**`dev`** (or **`custom`** in `donadiosolutions/opencodex`; stacked children +excepted), and rejects empty, thin, or malformed descriptions. If changed paths include files under `gui/`, include a screenshot of the UI change in the description; the check re-runs on description edits until the screenshot is present. Drag the image into the description editor rather than committing it, or, when uploading from @@ -440,16 +444,15 @@ local-validation box is an author attestation only — fork contributors cannot repository CI; a maintainer has to — so the gate never disproves it; a new push still resets every box. A disproved claim unticks the matching box and keeps the PR a draft. -Authors with repository push permission skip the ancestry heuristic only. As with approval requirements in -[`MAINTAINERS.md`](./MAINTAINERS.md), the ancestry heuristic is a CI check -rather than a branch rule. The branches themselves are protected: `dev`, -`main`, and `preview` each require a pull request and block force-pushes and deletion. +As with approval requirements in [`MAINTAINERS.md`](./MAINTAINERS.md), the +wrong-base rule is a CI check rather than a branch rule. The branch itself is +protected: `dev` requires a pull request and blocks force-pushes and deletion. For `dev` only, a current maintainer with GitHub `maintain` or `admin` access may explicitly integrate through a PR without another maintainer approval, including their own PR, under the policy in `MAINTAINERS.md`. Record the decision and exact-head CI evidence; keep outstanding maintainer objections and security review separate. The bypass is PR-only, so a direct push to `dev` remains rejected regardless of -local hook settings. Contributor review and `main`/`preview` rules remain unchanged. +local hook settings. Contributor review rules remain unchanged. [`MAINTAINERS.md`](./MAINTAINERS.md) is authoritative for review and merge policy (approvals, CI requirements, security review, promotion). This file diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index c4a80069fd7..6a91b180208 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -52,6 +52,24 @@ The following instructions concern **starring the repository on GitHub**. the matter permanently — do not argue it, re-frame it, or raise it again later. +### Telemetry + +Anonymous daily usage telemetry defaults to on only after a human has seen the +disclosure notice (in the GUI or an interactive TTY prompt). An agent must never +enable telemetry or acknowledge the telemetry disclosure on the user's behalf: +`PUT /api/telemetry` and `ocx telemetry enable` refuse agent-driven callers with +`agent_consent_required` (and `ocx telemetry enable` refuses a non-interactive +terminal with `tty_required`), and `ocx config set`/`unset telemetry` refuse every +caller. Opting out (`ocx telemetry disable`, `OCX_TELEMETRY=0` or `DO_NOT_TRACK=1`) +is always permitted. + +A public leaderboard profile is a separate user-consent action: it publishes a handle and +daily usage totals under the user's chosen name. An agent must never connect one or answer +its prompt. `POST /api/telemetry/profile` requires a human dashboard session, and +`ocx telemetry profile connect` refuses agent-driven callers with `agent_consent_required` +and non-interactive terminals with `tty_required`. Disconnecting +(`ocx telemetry profile disconnect`), which deletes the profile, is always permitted. + ## Where things are installed An agent asked to install, inspect, repair, or remove opencodex needs to know what is written @@ -92,7 +110,7 @@ A host that has both a Task Scheduler entry and a WinSW service is in a conflict ### The CLI -`npm install -g @bitkyc08/opencodex` puts `ocx` on the PATH from npm's global prefix, and the +`npm install -g --allow-scripts=bun @bitkyc08/opencodex` puts `ocx` on the PATH from npm's global prefix, and the Bun runtime it needs is bundled inside that package. There is no separate runtime to install and no WSL layer on Windows. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbcd248f8f8..a5944a82f56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,9 +15,13 @@ Thanks for helping with opencodex. ## Branches -- `dev` — the only integration target for pull requests. -- `main` — releases only; moves by maintainer-controlled promotion from `dev`. -- `preview` — prerelease train. +- `dev` — the only integration target for pull requests and the repository + default branch. Stable and preview releases are cut from eligible `dev` + commits by the release workflow, which stamps the version into build + checkouts. +- `main` and `preview` were deleted once the single-branch model was in place. + Their released history remains reachable through tags; neither is an + integration, promotion, or review target. The `dev2-go` Go native-port line has been retired. Its history is archived at [lidge-jun/opencodex-go-archive](https://github.com/lidge-jun/opencodex-go-archive), @@ -40,7 +44,7 @@ Bun through that bundled dependency. - Pull requests run **only the fast Ubuntu subset**. - Full cross-platform (macOS/Windows) CI runs **only** via manual - `workflow_dispatch` and on push to `main`/`preview` (release lines). + `workflow_dispatch` on `dev`; there is no push trigger. - **Never** add a `dev` push trigger, or `pull_request` runs for macOS/Windows jobs, to any workflow. PRs that change this policy will be closed. @@ -72,8 +76,8 @@ A ready-for-review PR is the author's claim that the change is complete, underst The suite uses Bun 1.4.4 (`testRunnerBun`) while the shipped runtime is 1.4.4. `scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`; install that version locally if it is missing. -The roles remain independently checked even when their versions match. Promotion requires -a successful full cross-platform `lane=all` run on the candidate head. +The roles remain independently checked even when their versions match. Release selection +requires a successful full cross-platform `lane=all` run on the exact candidate `dev` SHA. Run `bun run test` before review readiness. If the full local suite is too costly for the task or available resources, run at least focused regression tests for diff --git a/Dockerfile b/Dockerfile index 10012088bc3..c03109e5cae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,6 +96,8 @@ COPY --chown=bun:bun assets/sponsors/orcarouter.png assets/sponsors/tokenlab-lig COPY --chown=bun:bun README.md SPONSORS.md AGENTS_INSTALL.md LICENSE .npmignore ./ COPY --chown=bun:bun native/remote-workspace-helper/Cargo.toml native/remote-workspace-helper/Cargo.lock ./native/remote-workspace-helper/ COPY --chown=bun:bun native/remote-workspace-helper/src ./native/remote-workspace-helper/src +COPY --chown=bun:bun native/ocx-child/Cargo.toml native/ocx-child/Cargo.lock ./native/ocx-child/ +COPY --chown=bun:bun native/ocx-child/src ./native/ocx-child/src RUN mkdir -p /out \ && chmod 0755 bin/ocx.mjs \ && find gui/dist -type d -exec chmod 0755 {} + \ diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 52bcf6075cc..ae7a07cf98f 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -28,13 +28,12 @@ when a maintainer steps down. ## Review and merge policy -- Pull requests target `dev`. It is the only integration line, and promotion to - `main` happens only from `dev`. The target-branch check accepts `dev` alone - upstream; `donadiosolutions/opencodex` also accepts its `custom` integration - branch for fork changes. -- The **`enforce-target`** CI check rejects pull requests whose head - ancestry sits on the **`main`** tip while far behind **`dev`**, and rejects - empty, thin, or malformed descriptions; PRs that change files under `gui/` +- Upstream pull requests target `dev`, the only development and integration line. + In `donadiosolutions/opencodex`, fork-specific pull requests target `custom`; + the target-branch check also continues to accept `dev`. +- The **`enforce-target`** CI check rejects pull requests whose base is not + **`dev`** (or **`custom`** in `donadiosolutions/opencodex`; stacked children excepted), + and rejects empty, thin, or malformed descriptions; PRs that change files under `gui/` must include a screenshot of the UI change in the description. Drag the image into the description instead of committing it to the PR branch; command-line uploads use the `pr-assets` branch and a commit-SHA link. @@ -59,9 +58,8 @@ when a maintainer steps down. never disproves it; a new push still resets every box. A disproved claim unticks the matching box and keeps the PR a draft. - Authors with repository push permission skip the ancestry heuristic only. As - with the approval requirement above, this part is enforced by convention; - the ruleset does not check ancestry (see the note under the change log). + As with the approval requirement above, the wrong-base rule is enforced by CI + rather than by the ruleset. - Pull requests require successful required CI checks before merge. Contributor pull requests normally require approval from at least one maintainer other than the author. - **Batched merges.** A pull request's own CI is the fast lane by default: `gates` plus @@ -72,17 +70,17 @@ when a maintainer steps down. contracts in ways an import graph cannot see (subprocesses, files read as data, generated output). Merge green pull requests one at a time; after roughly every five merges, run `gh workflow run ci.yml --ref dev` (the default `lane=all`). If that run is red, stop merging, - fix `dev` from the batch it covers, and resume once it is green. Always run it before a - promotion to `main`. + fix `dev` from the batch it covers, and resume once it is green. A complete successful + dispatch is also the cross-platform evidence for the exact commit it tests. - A current maintainer with GitHub `maintain` or `admin` access may explicitly integrate a pull request into `dev` without another maintainer's approval, including their own pull request. Record that choice and the exact-head verification in the pull-request description or comment. This is maintainer integration, not a self-approval or an independent review. Outstanding maintainer change requests must still be resolved or explicitly withdrawn. Technical review, attribution, documentation and security-review duties remain in force. -- The maintainer-integration exception applies only to `dev`. It does not change review rules - for `main` or `preview`, grant contributor authors approval authority, or permit direct pushes, - force-pushes or branch deletion. Authors do not submit approving reviews of their own work. +- The maintainer-integration exception applies only to `dev`. It does not grant contributor + authors approval authority or permit direct pushes, force-pushes or branch deletion. Authors do + not submit approving reviews of their own work. - Authentication, credential handling, GitHub Actions, release automation, dependency installation, and other security-boundary changes require explicit security review. - A new or promoted provider preset is a credential-destination change. Before merge it needs the @@ -99,29 +97,19 @@ when a maintainer steps down. - Integration uses pull requests, including urgent maintainer repairs. The PR-only ruleset bypass does not authorize direct pushes; incident changes to branch protection require a separate owner decision. -- Promotion from `dev` to `main` and npm releases is maintainer-controlled. -- **Opening a release starts by moving `dev`'s version line forward.** Before cutting - a release, `dev` must already outrank the version being released; `release.yml` - asserts this and refuses to publish otherwise. Dispatch - `.github/workflows/dev-version-bump.yml` with the intended version, merge the pull - request it opens, then promote and release. When `dev` already outranks the target - — a preview cut, or a stable hotfix below `dev`'s line — no move is needed and the - workflow reports `changed=false`. - - Opening a preview for the next core ends the current patch line. After - `vX.Y.0-preview.*` is tagged, a fix ships as part of `X.Y.0`, not as - `X.(Y-1).(Z+1)`. The release helper refuses such a bump rather than producing a - version the repository would reject. This is a deliberate policy restriction, not - a claim that lower stable patches were historically unused. - - Done after the publish, as this repository did for ten releases (`32529c2b2`, - `e4a85d134`, `076ad3036`, `befcac3e1`, then #3045, #3076, #3127, #3265, #3354, - #3434), it leaves `dev` and every open pull request carrying a failure contributors - cannot fix from their own diff. The pull request itself does not go away — `Protect - dev` requires a reviewed merge. If the pre-move is missed and publication somehow - succeeds, dispatch `dev-version-bump.yml` from the default branch with the released - version and `mode=repair`, then merge the repair pull request. Design: - `devlog/_plan/260904_release_version_line/`. +- npm releases are maintainer-controlled. Preview and stable releases use an eligible + `dev` commit with successful full manual cross-platform CI; the workflow stamps the + release version into build checkouts instead of committing a pre-release version bump + or promoting a branch. Releases share one concurrency group and preserve the global + version ordering. The `main` and `preview` branches were deleted after the + single-branch model was in place; released history remains reachable through + tags, and neither branch is a release target. +- Publication remains disabled until the coordinator completes the cutover checks, + including the reviewed legacy lineage baseline, npm trusted-publisher configuration, + default branch and rulesets, Pages settings, and a hosted dry-run. The variable + `OCX_DEV_RELEASE_PUBLISH_ENABLED` must equal `true` for a real publication; missing + or other values keep the hold in place. A hosted release dry-run uses the trusted + `dev` workflow and does not publish. ## The retired `dev2-go` line @@ -159,7 +147,7 @@ Adding or removing a maintainer requires: - 2026-09-06 — The owner authorized explicit maintainer integration into `dev` without a second maintainer approval. Both current maintainers have `admin` access. The dev-only PR bypass includes GitHub's `admin` and `maintain` roles; `write` access alone is insufficient. Contributor - review remains the default and the `main`/`preview` rules are unchanged. The optional + review remained the default; the former `main`/`preview` branches were later retired. The optional `scripts/ci/assert-mergeable-review.sh --maintainer-integration [repo]` path checks the authenticated actor against the trusted `dev` roster and live repository permissions, preserves outstanding maintainer objections, and binds its result to the current head and base. @@ -208,10 +196,10 @@ Adding or removing a maintainer requires: this addition does not widen the review surface for them. Code-owner approval and the maintainer-approval requirement above are both - enforced, not conventions. `dev`, `main`, and `preview` each carry an active - repository ruleset — the classic `/branches/{branch}/protection` endpoint - returns 404 for them, which is why this file long described the repository as - unprotected. `Protect dev` (id 20763889) requires a pull request with one + enforced, not conventions. `dev` carries an active repository ruleset; the + classic `/branches/{branch}/protection` endpoint can return 404 for rulesets, + which is why this file long described the repository as unprotected. + `Protect dev` (id 20763889) requires a pull request with one approving review, code-owner review, and extra approval for unattributed changes, and it blocks deletion and non-fast-forward pushes. Allowed merge methods are merge and squash; rebase merges are off. diff --git a/README.md b/README.md index 8197f747220..2255ae29e06 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,17 @@

Use the models you choose with Codex, Claude Code, Claude Desktop and Grok Build,
and connect OpenCode, Pi, Qoder, WorkBuddy, Kilo, Cline, T3 Code and more through managed configs.

See every supported tool →

+

Part of GOAT: run your coding agents on any model, on any machine → trygoat.ai

Follow @claudeebum on X npm version - license + license node version

```bash -npm install -g @bitkyc08/opencodex +npm install -g --allow-scripts=bun @bitkyc08/opencodex ocx start ``` @@ -48,7 +49,7 @@ Pick a provider and go — same workflow, different brain. - opencodex demo — running a task in the Codex app on a routed non-OpenAI model + opencodex demo — running a task in the Codex app on a routed non-OpenAI model @@ -60,7 +61,7 @@ Opus answers, then hands the task to a GPT-5.6 Sol subagent. - Claude Desktop answering as Claude Opus 4.8, then dispatching a GPT-5.6 Sol subagent through opencodex + Claude Desktop answering as Claude Opus 4.8, then dispatching a GPT-5.6 Sol subagent through opencodex @@ -72,7 +73,7 @@ Sol drives the session and calls a Kimi K3 subagent. - Grok Build running GPT-5.6 Sol through opencodex and calling a Kimi K3 subagent + Grok Build running GPT-5.6 Sol through opencodex and calling a Kimi K3 subagent @@ -86,16 +87,14 @@ accepts the Responses, Chat Completions and Anthropic Messages APIs and translat whatever your provider speaks, carrying streaming, tool calls, reasoning and images where the client, model and provider support them. Use Claude, Gemini, Grok, GLM, DeepSeek, Kimi, Qwen, Ollama and other providers with Codex, Claude Code, Claude Desktop, Grok Build and the tools listed on the -[client support page](https://opencodex.me/reference/client-support/). It can also manage a **ChatGPT account pool** for Codex auth: add accounts, -refresh their quotas in the dashboard, and let new sessions auto-route to the lowest-usage healthy -account while existing threads stay pinned to the account that started them. +[client support page](https://opencodex.me/reference/client-support/). ## Quick start ### Personal install (CLI) ```bash -npm install -g @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically +npm install -g --allow-scripts=bun @bitkyc08/opencodex # Node 18+; the Bun runtime is bundled automatically ocx start # proxy + dashboard on localhost:10100 ``` @@ -147,17 +146,6 @@ step needs macOS). The [Desktop App guide](https://opencodex.me/guides/desktop-a -### ChatGPT account pool - -opencodex can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, -refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use -the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex -threads normally retain affinity to the account that started them, so long SSH, tmux, or -mobile-connected sessions do not jump accounts mid-conversation — but quota re-evaluation, failover, -account exclusion, affinity expiry, or 401/403 and 429 recovery can rebind them. Give the accounts a -selection order when one of them — usually your Codex Desktop login — should only be reached for -once the others are drained. - ### Sponsors Sponsors keep opencodex maintained across every upstream protocol change. Interested? @@ -218,7 +206,7 @@ services: hub: pull_policy: build build: - context: https://github.com/lidge-jun/opencodex.git#main + context: https://github.com/lidge-jun/opencodex.git#dev dockerfile: Dockerfile target: runtime args: @@ -265,7 +253,7 @@ they reach the npm package. For agents ```bash -npm install -g @bitkyc08/opencodex +npm install -g --allow-scripts=bun @bitkyc08/opencodex ocx start # or `ocx service` ocx init # interactive setup: writes ~/.opencodex/config.json and wires Codex ``` @@ -297,16 +285,6 @@ see the [installation docs](https://opencodex.me/getting-started/installation/). - **Use any LLM with Codex, Claude Code, Claude Desktop, and Grok Build** — 40+ providers out of the box, each keeping its own native UI. -- **Pool ChatGPT accounts** — thread affinity, quota-aware auto-switching, cooldown and - fail-closed auth handling. - - > **Provider-policy note:** Account pooling is for routing and operational resilience only; it does - > not guarantee protection from provider rate limits, enforcement, suspension, or other account - > actions. OpenCodex does not endorse using additional accounts to circumvent provider limits or - > sharing account credentials between people. You are responsible for complying with each - > provider's current terms. See the - > [Codex Auth account-pool guidance](https://opencodex.me/guides/web-dashboard/#codex-auth-and-account-pools) - > and [OpenAI's current Terms of Use](https://openai.com/policies/terms-of-use/). - **Combos** — one virtual model id with failover or weighted round-robin across providers. See the [combo guide](https://opencodex.me/guides/combos/). - **Sub-agents on any model** — feature routed models in Codex's sub-agent picker, with v1/v2 @@ -456,6 +434,32 @@ loopback (`"hostname": "0.0.0.0"`) **requires** a bearer token — the proxy ref without `OPENCODEX_API_AUTH_TOKEN`, and every client request must carry it as `x-opencodex-api-key`. Details: [configuration reference](https://opencodex.me/reference/configuration/). +## Advanced usage + +Self-hosted options for power users. Review each provider's terms before you enable them. + +### ChatGPT account pool + +opencodex can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, +refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use +the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex +threads normally retain affinity to the account that started them, so long SSH, tmux, or +mobile-connected sessions do not jump accounts mid-conversation — but quota re-evaluation, failover, +account exclusion, affinity expiry, or 401/403 and 429 recovery can rebind them. Give the accounts a +selection order when one of them — usually your Codex Desktop login — should only be reached for +once the others are drained. + +- **Pool ChatGPT accounts** — thread affinity, quota-aware auto-switching, cooldown and + fail-closed auth handling. + + > **Provider-policy note:** Account pooling is for routing and operational resilience only; it does + > not guarantee protection from provider rate limits, enforcement, suspension, or other account + > actions. OpenCodex does not endorse using additional accounts to circumvent provider limits or + > sharing account credentials between people. You are responsible for complying with each + > provider's current terms. See the + > [Codex Auth account-pool guidance](https://opencodex.me/guides/web-dashboard/#codex-auth-and-account-pools) + > and [OpenAI's current Terms of Use](https://openai.com/policies/terms-of-use/). + ## Documentation The public docs — install, providers, routing, combos, sub-agents, sidecars, integrations, and @@ -498,7 +502,7 @@ Free code signing provided by [SignPath.io](https://about.signpath.io/), certifi - Committers and reviewers: [lidge-jun](https://github.com/lidge-jun) - Approvers: [lidge-jun](https://github.com/lidge-jun) -- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. +- Privacy: opencodex sends one anonymous daily usage summary (a per-day install hash that changes daily, version, release channel, platform, install channel, provider kind, coarse model family, active minutes and request, token and error counts; never prompts, model outputs, keys, accounts, hostnames or paths) to telemetry.opencodex.me, and only after the person installing or operating it has seen the notice in the dashboard or an interactive CLI prompt and has not declined. It can be turned off in that notice, in the Windows installer ("Send anonymous usage statistics", checked by default), in Proxy details, or with ocx telemetry disable, OCX_TELEMETRY=0 or DO_NOT_TRACK=1. Only project-wide aggregates of this anonymous data are public. A separate public leaderboard profile is off unless you connect one yourself (dashboard Home or ocx telemetry profile connect); it shares only a chosen handle, display name and colour and your daily request, token and model-family totals, never your anonymous telemetry ID, and disconnecting deletes it. Everything else goes only to the providers and services you configure. See the [telemetry guide](https://opencodex.me/guides/telemetry/). ## Disclaimer diff --git a/SPONSORS.md b/SPONSORS.md index 97506d9ce9a..46d5d644459 100644 --- a/SPONSORS.md +++ b/SPONSORS.md @@ -95,7 +95,11 @@ draft agreement. request reaches. - No relaxation of the [security review](./MAINTAINERS.md) that applies to authentication, credentials, or workflow changes. -- No access to user data, request logs, or telemetry; opencodex does not collect any. +- No access to user data, request logs, or individual telemetry reports. The only telemetry opencodex + collects is the anonymous daily usage summary described in the + [privacy statement](./README.md#code-signing-policy), which users can turn off; its project-wide + aggregates are public, as are the leaderboard profiles that users choose to connect, and sponsors + see nothing beyond them. - No say over unrelated issues, pull requests, or the release schedule. ## Current sponsors diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 72eff87dfcf..ef5b11ad33d 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -331,7 +331,7 @@ async function runPackageManagerSelfUpdate(manager, child) { const latestInvocation = managerInvocation(["view", `${PKG}@${selector}`, "version"]); const installArgs = manager === "pnpm" ? ["add", "-g", "--allow-build=bun", `${PKG}@${selector}`] - : ["install", "-g", `${PKG}@${selector}`]; + : ["install", "-g", "--allow-scripts=bun", `${PKG}@${selector}`]; const installInvocation = managerInvocation(installArgs); if (!latestInvocation || !installInvocation) { console.error(`opencodex: could not resolve ${manager} from a trusted absolute PATH entry; aborting before stopping the proxy.`); diff --git a/desktop/scripts/telemetry-installer-dialog.ts b/desktop/scripts/telemetry-installer-dialog.ts new file mode 100644 index 00000000000..1e36eb0060d --- /dev/null +++ b/desktop/scripts/telemetry-installer-dialog.ts @@ -0,0 +1,188 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + TELEMETRY_DISCLOSURE_HEADING, + TELEMETRY_DISCLOSURE_POINTS, + TELEMETRY_POLICY_URL, +} from "../../src/telemetry/disclosure"; + +/** + * Generates the WiX fragment that adds the telemetry choice to the Windows installer. + * + * SignPath Foundation's conditions say software that sends usage data must describe it in a privacy + * policy, display that policy during installation, and include an installation option to disable + * it. The fragment inserts a dialog between the welcome and install-directory pages of Tauri's + * WixUI_InstallDir sequence. It shows the same disclosure the CLI prompt prints, links the policy, + * and offers a "Send anonymous usage statistics" checkbox, checked by default (unchecked when the + * install already carries an opt-out). The choice and a stamp of when it was made go to HKLM, so a + * silent install run as SYSTEM covers every user, and an upgrade carries the previous choice forward. + * The desktop app (`installer_telemetry.rs`) persists an opt-out into each user's opencodex config + * once per stamp, before it starts or attaches to a proxy. + * + * Silent installs choose with the public property: `msiexec /i OpenCodex.msi /qn OCX_TELEMETRY_CHOICE=optout`. + * + * `bun desktop/scripts/telemetry-installer-dialog.ts --write` regenerates the committed file; + * `--check` fails when it is stale. + */ + +export const TELEMETRY_INSTALLER_FRAGMENT_PATH = "wix/telemetry-choice.wxs"; +export const TELEMETRY_INSTALLER_COMPONENT_GROUP = "OcxTelemetryInstallerChoice"; +export const TELEMETRY_INSTALLER_DIALOG = "OcxTelemetryDlg"; +export const TELEMETRY_INSTALLER_REGISTRY_KEY = "Software\\OpenCodex\\Telemetry"; +export const TELEMETRY_INSTALLER_REGISTRY_VALUE = "InstallerChoice"; +export const TELEMETRY_INSTALLER_STAMP_VALUE = "InstallerChoiceStamp"; +export const TELEMETRY_INSTALLER_STAMP_PROPERTY = "OCX_TELEMETRY_STAMP"; +export const TELEMETRY_INSTALLER_CHOICE_PROPERTY = "OCX_TELEMETRY_CHOICE"; +export const TELEMETRY_INSTALLER_CHECKBOX_PROPERTY = "OCX_TELEMETRY_SEND"; +export const TELEMETRY_INSTALLER_CHECKBOX_TEXT = "Send anonymous usage statistics"; +// Shown under the checkbox: "send" is never acted on, and a new opt-out applies to every account. +export const TELEMETRY_INSTALLER_KEEP_NOTE = + "Checked keeps each account's choice; unchecking turns it off for all."; +export const TELEMETRY_INSTALLER_SEND = "send"; +export const TELEMETRY_INSTALLER_OPT_OUT = "optout"; +// Fixed so upgrades keep one component identity for the HKLM values (new when the key path moved to HKLM). +const COMPONENT_GUID = "B3E1C7D2-58A4-4F0E-9C6B-7A2D41E95F08"; + +// Dialog units. WiX's standard dialogs are 370 x 270 with the button row below y = 234. +const TEXT_LEFT = 20; +const TEXT_WIDTH = 330; +const CHARS_PER_LINE = 72; +const LINE_HEIGHT = 10; +const POINTS_TOP = 50; +const POINT_GAP = 2; + +function xmlAttr(value: string): string { + // MSI formats control text, so brackets would be read as property references. + if (/[[\]{}]/.test(value)) throw new Error(`Installer text must not contain brackets or braces: ${value}`); + // The en-US MSI database uses code page 1252; keep to ASCII plus the bullet it also carries. + if (!/^[\x20-\x7E•]*$/.test(value)) throw new Error(`Installer text must stay within code page 1252: ${value}`); + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function wrappedLines(text: string): number { + let lines = 1; + let width = 0; + for (const word of text.split(" ")) { + const next = width === 0 ? word.length : width + 1 + word.length; + if (next > CHARS_PER_LINE && width > 0) { + lines += 1; + width = word.length; + } else { + width = next; + } + } + return lines; +} + +export function renderTelemetryInstallerFragment(): string { + const controls: string[] = []; + let y = POINTS_TOP; + TELEMETRY_DISCLOSURE_POINTS.forEach((point, index) => { + const height = wrappedLines(point) * LINE_HEIGHT; + controls.push( + ` `, + ); + y += height + POINT_GAP; + }); + const linkY = y + 2; + const checkboxY = linkY + 14; + const noteY = checkboxY + 17; + if (noteY + LINE_HEIGHT > 232) { + throw new Error(`The telemetry disclosure no longer fits the installer dialog (it ends at ${noteY + LINE_HEIGHT}).`); + } + return ` + + + + + + + + + + + + + + + + + + + + + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} <> "" AND ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> "${TELEMETRY_INSTALLER_SEND}" + + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} <> "" AND ${TELEMETRY_INSTALLER_STAMP_PROPERTY} = "" + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} = "" AND OCX_TELEMETRY_PREVIOUS_CHOICE <> "" + ${TELEMETRY_INSTALLER_STAMP_PROPERTY} = "" AND OCX_TELEMETRY_PREVIOUS_STAMP <> "" + + ${TELEMETRY_INSTALLER_STAMP_PROPERTY} <> "" + + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} = "" + + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~= "${TELEMETRY_INSTALLER_SEND}" + + + + + + + + + + + + + + +${controls.join("\n")} + + Read the full privacy policy]]> + + + + + 1 + + + ${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1" + NOT (${TELEMETRY_INSTALLER_CHECKBOX_PROPERTY} = "1") + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~<> OCX_TELEMETRY_PREVIOUS_CHOICE + ${TELEMETRY_INSTALLER_CHOICE_PROPERTY} ~= OCX_TELEMETRY_PREVIOUS_CHOICE AND OCX_TELEMETRY_CARRIED_STAMP <> "" + 1 + + + 1 + + + + NOT Installed + NOT Installed + + + +`; +} + +if (import.meta.main) { + const target = join(import.meta.dir, "..", "src-tauri", TELEMETRY_INSTALLER_FRAGMENT_PATH); + const expected = renderTelemetryInstallerFragment(); + if (process.argv.includes("--write")) { + writeFileSync(target, expected, "utf8"); + } else { + let actual = ""; + try { actual = readFileSync(target, "utf8"); } catch { /* missing counts as stale */ } + if (actual !== expected) { + console.error(`${TELEMETRY_INSTALLER_FRAGMENT_PATH} is stale; run bun desktop/scripts/telemetry-installer-dialog.ts --write`); + process.exit(1); + } + } +} diff --git a/desktop/scripts/verify-release-assets.ts b/desktop/scripts/verify-release-assets.ts index 0c3175791bb..d6845a470b8 100644 --- a/desktop/scripts/verify-release-assets.ts +++ b/desktop/scripts/verify-release-assets.ts @@ -11,9 +11,10 @@ * version and commit before it uploads anything, so publication can only ever * consume the verified bundle. */ -import { createHash, createPublicKey, verify as ed25519Verify, type KeyObject } from "node:crypto"; +import { createHash, type KeyObject } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; +import { decodePublicKey, verifyLegacyMinisignBytes } from "../../src/update/minisign.mjs"; import { standaloneArchiveName, standaloneTargets as sharedStandaloneTargets, @@ -122,8 +123,6 @@ export function verifyChecksums(dir: string): number { return checksumFiles.length; } -const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); - export interface MinisignPublicKey { keyId: string; publicKey: KeyObject; @@ -146,31 +145,10 @@ function decodeBox(text: string, what: string): string { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(payload); } -function boxLines(text: string): string[] { - // Accept minisign text with LF or CRLF and an optional terminal newline; - // signatures authenticate decoded bytes/comments, not transport line endings. - return text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"); -} - /** minisign public key: base64 of algorithm ("Ed") || key id (8) || raw key (32). */ export function parseMinisignPublicKey(text: string): MinisignPublicKey { - const lines = boxLines(text); - if (lines.length !== 2 || !lines[0]!.startsWith("untrusted comment: ")) { - throw new Error("Malformed minisign public key box"); - } - const payload = decodeBase64(lines[1]!, "minisign public key", 42); - const algorithm = payload.subarray(0, 2).toString("utf8"); - if (algorithm !== "Ed") { - throw new Error(`Unsupported minisign public key algorithm: ${JSON.stringify(algorithm)}`); - } - return { - keyId: payload.subarray(2, 10).toString("hex"), - publicKey: createPublicKey({ - key: Buffer.concat([ED25519_SPKI_PREFIX, payload.subarray(10, 42)]), - format: "der", - type: "spki", - }), - }; + const { keyId, publicKey } = decodePublicKey(text); + return { keyId, publicKey }; } /** The updater public key pinned in the Tauri configuration. */ @@ -187,34 +165,10 @@ export function loadUpdaterPublicKey(tauriConfPath: string): MinisignPublicKey { export function verifyUpdaterSignature(filePath: string, key: MinisignPublicKey): void { const signaturePath = `${filePath}.sig`; if (!existsSync(signaturePath)) throw new Error(`Missing signature: ${signaturePath}`); - const lines = boxLines(decodeBox(readFileSync(signaturePath, "utf8"), "Tauri signature")); - const trustedPrefix = "trusted comment: "; - if (lines.length !== 4 || !lines[0]!.startsWith("untrusted comment: ") - || !lines[2]!.startsWith(trustedPrefix)) { - throw new Error(`Malformed signature box in ${signaturePath}`); - } - const payload = decodeBase64(lines[1]!, "signature packet", 74); - const globalSignature = decodeBase64(lines[3]!, "comment signature", 64); - const algorithm = payload.subarray(0, 2).toString("utf8"); - if (algorithm !== "ED") { - throw new Error(`Unsupported signature algorithm in ${signaturePath}: ${JSON.stringify(algorithm)}`); - } - const keyId = payload.subarray(2, 10).toString("hex"); - if (keyId !== key.keyId) { - throw new Error(`Signature ${signaturePath} was made by key ${keyId}, not the pinned updater key ${key.keyId}`); - } - const signature = payload.subarray(10, 74); - // ED is ordinary Ed25519 over the BLAKE2b-512 digest, not Ed25519ph. - const digest = createHash("blake2b512").update(readFileSync(filePath)).digest(); - if (!ed25519Verify(null, digest, key.publicKey, signature)) { - throw new Error(`Signature verification failed for ${filePath}`); - } - // minisign signs the raw signature + trimmed trusted comment, without its - // prefix or line terminator. The original filename may differ after collection. - const trustedComment = lines[2]!.slice(trustedPrefix.length).trim(); - const message = Buffer.concat([signature, Buffer.from(trustedComment, "utf8")]); - if (!ed25519Verify(null, message, key.publicKey, globalSignature)) { - throw new Error(`Comment signature verification failed for ${filePath}`); + try { + verifyLegacyMinisignBytes(readFileSync(filePath), readFileSync(signaturePath, "utf8"), key); + } catch (error) { + throw new Error(`${error instanceof Error ? error.message : String(error)}: ${filePath}`); } } diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ddc9ff16b55..5404621b8ef 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2633,7 +2633,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.99.0" +version = "2.100.1" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 201998c2a13..c6477dc3977 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.99.0" +version = "2.100.1" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/src/installer_telemetry.rs b/desktop/src-tauri/src/installer_telemetry.rs new file mode 100644 index 00000000000..8631adc98b2 --- /dev/null +++ b/desktop/src-tauri/src/installer_telemetry.rs @@ -0,0 +1,545 @@ +//! The Windows installer's "Send anonymous usage statistics" choice. +//! +//! SignPath Foundation's conditions require software that sends usage data to include an +//! installation option that disables it. The MSI page generated into +//! `desktop/src-tauri/wix/telemetry-choice.wxs` records the choice machine-wide under +//! `HKLM\Software\OpenCodex\Telemetry`: `InstallerChoice` (`send` or `optout`) and +//! `InstallerChoiceStamp` (when it was chosen). HKLM is what makes a silent install run as SYSTEM +//! (Intune, SCCM) or approved by another administrator reach the people who later use the app; an +//! upgrade carries the previous choice and stamp forward. The same two values under the user's own +//! `HKCU\Software\OpenCodex\Telemetry` are honoured too, as a per-user choice. +//! +//! Before the app resolves, attaches to or starts a proxy, this module carries an opt-out into the +//! current user's opencodex config by running the bundled `ocx telemetry disable`, which persists +//! `telemetry.decision = "declined"` (on a fresh install it is held as `pendingDecision` without +//! creating `config.json`). It then records the applied stamp in that user's HKCU, one value per +//! scope, so each opt-out applies once per user and a later dashboard choice is not overridden until +//! an install records a new opt-out. `send` is never acted on, and this module has no path that +//! enables telemetry, so no installer choice can turn a decline back on. +//! +//! When the decline could not be persisted, a sidecar this app starts runs with +//! `OCX_TELEMETRY=0`, and the save is retried in the background (`RETRY_DELAYS`) and again on the +//! next launch. A proxy this app only attaches to was started elsewhere with its own environment; +//! it follows the saved decline once a retry succeeds (it re-reads the config on every tick). If a +//! decline was saved but its stamp could not be recorded, the next launch saves it again, which can +//! override a dashboard enable made in between; that errs toward privacy. +//! +//! An MSI install does not put `ocx.exe` on PATH; the terminal command is installed by this app. +//! On a first install the app therefore runs before any proxy from that install can. After the app +//! has installed the terminal command, `ocx service install` can register a logon task that starts a +//! proxy without the app; an opt-out recorded by a later manual upgrade reaches that proxy's config +//! when the app next starts, and until then the service may still make its daily send. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use tauri::AppHandle; +use tauri_plugin_shell::ShellExt; +use tokio::time::{timeout_at, Instant}; + +// Read and written only on Windows; the names are pinned against the WiX fragment by a test. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub const REGISTRY_KEY: &str = r"Software\OpenCodex\Telemetry"; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub const REGISTRY_VALUE: &str = "InstallerChoice"; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub const STAMP_VALUE: &str = "InstallerChoiceStamp"; +/// Written by this app under HKCU: the stamp of the last machine-wide opt-out applied for this user. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub const APPLIED_STAMP_VALUE: &str = "AppliedInstallerStamp"; +/// Written by this app under HKCU: the stamp of the last per-user opt-out applied. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub const APPLIED_USER_STAMP_VALUE: &str = "AppliedUserInstallerStamp"; +// The installer's value for an opt-out, pinned against the WiX fragment by a test; the app treats any +// value other than `SEND` as an opt-out. +#[allow(dead_code)] +pub const OPT_OUT: &str = "optout"; +pub const SEND: &str = "send"; + +static SIDECAR_ENV_OPT_OUT: AtomicBool = AtomicBool::new(false); + +/// The opt-out step's own budget, so a slow first `ocx` start cannot use up the startup deadline. +const STEP_CAP: Duration = Duration::from_secs(10); +/// Background retries after a failed save, so an attached proxy does not keep its old setting. +pub const RETRY_DELAYS: [Duration; 4] = [ + Duration::from_secs(10), + Duration::from_secs(30), + Duration::from_secs(120), + Duration::from_secs(600), +]; +const RETRY_TIMEOUT: Duration = Duration::from_secs(30); + +/// Where an installer choice was recorded. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + /// `HKLM`: every account on the computer. + Machine, + /// `HKCU`: this account only. Only the Windows registry reader records it. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + User, +} + +impl Scope { + /// The HKCU value this app writes once an opt-out from this scope is applied. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub fn applied_value(self) -> &'static str { + match self { + Scope::Machine => APPLIED_STAMP_VALUE, + Scope::User => APPLIED_USER_STAMP_VALUE, + } + } +} + +/// A choice the installer recorded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstallerChoice { + pub scope: Scope, + pub choice: String, + pub stamp: String, +} + +/// Registry access, injected so the decision logic is tested without Windows. +pub trait InstallerRegistry { + /// The choices the installer recorded, in any order. + fn choices(&self) -> Vec; + /// The stamp this app last applied for `scope` in this account. + fn applied_stamp(&self, scope: Scope) -> Option; + /// Record that the opt-out with `stamp` from `scope` is applied in this account. + fn mark_applied(&self, scope: Scope, stamp: &str) -> bool; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Outcome { + /// No opt-out is pending for this user: no choice, `send`, or one already applied. + NothingPending, + /// The decline is in the user's config and every pending stamp is recorded. + Applied, + /// The decline is in the config, but a stamp could not be recorded; the next launch re-applies it. + AppliedUnmarked, + /// The decline could not be persisted; a sidecar this app starts runs with `OCX_TELEMETRY=0`. + NotPersisted, +} + +/// Fail closed like the installer: any recorded value other than `send` (or nothing) is an opt-out. +fn is_opt_out(choice: &str) -> bool { + let choice = choice.trim(); + !choice.is_empty() && !choice.eq_ignore_ascii_case(SEND) +} + +/// The opt-outs this account has not applied yet, machine-wide first. `send` is never pending. +pub fn pending_opt_outs(registry: &R) -> Vec { + let mut pending: Vec = registry + .choices() + .into_iter() + .filter(|choice| is_opt_out(&choice.choice)) + .filter(|choice| { + registry + .applied_stamp(choice.scope) + .as_deref() + .map(str::trim) + != Some(choice.stamp.trim()) + }) + .collect(); + pending.sort_by_key(|choice| choice.scope != Scope::Machine); + pending +} + +/// `ocx telemetry disable` exits 0 only once the decline is persisted. +pub fn decline_persisted(exit_code: Option) -> bool { + exit_code == Some(0) +} + +/// Apply the pending opt-outs: one persisted decline covers all of them, and only then is each +/// stamp recorded. +pub fn settle( + registry: &R, + pending: &[InstallerChoice], + persist: impl FnOnce() -> bool, +) -> Outcome { + if pending.is_empty() { + return Outcome::NothingPending; + } + if !persist() { + return Outcome::NotPersisted; + } + let mut all_marked = true; + for choice in pending { + all_marked &= registry.mark_applied(choice.scope, choice.stamp.trim()); + } + if all_marked { + Outcome::Applied + } else { + Outcome::AppliedUnmarked + } +} + +/// Whether the sidecar this app starts must carry `OCX_TELEMETRY=0`. +pub fn sidecar_env_opt_out() -> bool { + SIDECAR_ENV_OPT_OUT.load(Ordering::SeqCst) +} + +/// Carry a pending installer opt-out into the current user's opencodex config. +pub async fn apply(app: &AppHandle, deadline: Instant) -> Outcome { + let registry = os::SystemRegistry; + let pending = pending_opt_outs(®istry); + let persisted = if pending.is_empty() { + false + } else { + disable(app, deadline.min(Instant::now() + STEP_CAP)).await + }; + let outcome = settle(®istry, &pending, || persisted); + match outcome { + Outcome::NotPersisted => { + SIDECAR_ENV_OPT_OUT.store(true, Ordering::SeqCst); + crate::logging::log_once( + "the installer's telemetry opt-out could not be saved", + "a proxy this app starts runs with OCX_TELEMETRY=0; saving is retried in the background", + ); + let app = app.clone(); + tauri::async_runtime::spawn(async move { retry(app, pending).await }); + } + Outcome::AppliedUnmarked => crate::logging::log_once( + "the installer's telemetry opt-out was saved but not recorded as applied", + "the next launch saves the decline again", + ), + Outcome::NothingPending | Outcome::Applied => {} + } + outcome +} + +async fn retry(app: AppHandle, pending: Vec) { + for delay in RETRY_DELAYS { + tokio::time::sleep(delay).await; + if disable(&app, Instant::now() + RETRY_TIMEOUT).await { + if settle(&os::SystemRegistry, &pending, || true) == Outcome::AppliedUnmarked { + crate::logging::log_once( + "the installer's telemetry opt-out was saved but not recorded as applied", + "the next launch saves the decline again", + ); + } + return; + } + } + crate::logging::log_once( + "the installer's telemetry opt-out could not be saved after retries", + "the next launch tries again; check that config.json is writable", + ); +} + +async fn disable(app: &AppHandle, deadline: Instant) -> bool { + let command = match app.shell().sidecar("ocx") { + Ok(command) => command.args(["telemetry", "disable", "--json"]), + Err(_) => return false, + }; + match timeout_at( + deadline, + crate::process_spawn::with_spawn_async(command.output()), + ) + .await + { + Ok(Ok(output)) => decline_persisted(output.status.code()), + _ => false, + } +} + +#[cfg(target_os = "windows")] +mod os { + use super::{InstallerChoice, InstallerRegistry, Scope}; + use winreg::enums::{ + HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY, + }; + use winreg::RegKey; + + pub struct SystemRegistry; + + fn read(key: &RegKey, scope: Scope) -> Option { + let choice = key.get_value::(super::REGISTRY_VALUE).ok()?; + let stamp = key + .get_value::(super::STAMP_VALUE) + .unwrap_or_default(); + Some(InstallerChoice { + scope, + choice, + stamp, + }) + } + + impl InstallerRegistry for SystemRegistry { + fn choices(&self) -> Vec { + let mut choices = Vec::new(); + // The 64-bit view first, the 32-bit view second. + for view in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] { + if let Some(choice) = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey_with_flags(super::REGISTRY_KEY, KEY_READ | view) + .ok() + .and_then(|key| read(&key, Scope::Machine)) + { + choices.push(choice); + break; + } + } + if let Some(choice) = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags(super::REGISTRY_KEY, KEY_READ) + .ok() + .and_then(|key| read(&key, Scope::User)) + { + choices.push(choice); + } + choices + } + + fn applied_stamp(&self, scope: Scope) -> Option { + RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags(super::REGISTRY_KEY, KEY_READ) + .ok()? + .get_value::(scope.applied_value()) + .ok() + } + + fn mark_applied(&self, scope: Scope, stamp: &str) -> bool { + RegKey::predef(HKEY_CURRENT_USER) + .create_subkey(super::REGISTRY_KEY) + .and_then(|(key, _)| key.set_value(scope.applied_value(), &stamp.to_owned())) + .is_ok() + } + } +} + +#[cfg(not(target_os = "windows"))] +mod os { + use super::{InstallerChoice, InstallerRegistry, Scope}; + + /// Only the Windows installer records a choice. + pub struct SystemRegistry; + + impl InstallerRegistry for SystemRegistry { + fn choices(&self) -> Vec { + Vec::new() + } + + fn applied_stamp(&self, _scope: Scope) -> Option { + None + } + + fn mark_applied(&self, _scope: Scope, _stamp: &str) -> bool { + true + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + decline_persisted, pending_opt_outs, settle, InstallerChoice, InstallerRegistry, Outcome, + Scope, + }; + use std::cell::{Cell, RefCell}; + use std::collections::HashMap; + + /// An in-memory registry: the installer's choices plus this account's applied stamps. + #[derive(Default)] + struct FakeRegistry { + choices: Vec, + applied: RefCell>, + refuse_marks: bool, + } + + impl FakeRegistry { + fn with(choices: &[(Scope, &str, &str)]) -> Self { + Self { + choices: choices + .iter() + .map(|(scope, choice, stamp)| InstallerChoice { + scope: *scope, + choice: (*choice).to_owned(), + stamp: (*stamp).to_owned(), + }) + .collect(), + ..Self::default() + } + } + + fn applied(&self, scope: Scope) -> Option { + self.applied.borrow().get(scope.applied_value()).cloned() + } + + fn set_applied(&self, scope: Scope, stamp: &str) { + self.applied + .borrow_mut() + .insert(scope.applied_value(), stamp.to_owned()); + } + } + + impl InstallerRegistry for FakeRegistry { + fn choices(&self) -> Vec { + self.choices.clone() + } + + fn applied_stamp(&self, scope: Scope) -> Option { + self.applied(scope) + } + + fn mark_applied(&self, scope: Scope, stamp: &str) -> bool { + if self.refuse_marks { + return false; + } + self.set_applied(scope, stamp); + true + } + } + + /// Run one launch: returns the outcome and whether a decline was persisted. + fn launch(registry: &FakeRegistry, persist_ok: bool) -> (Outcome, bool) { + let persisted = Cell::new(false); + let pending = pending_opt_outs(registry); + let outcome = settle(registry, &pending, || { + persisted.set(true); + persist_ok + }); + (outcome, persisted.get()) + } + + #[test] + fn a_machine_wide_opt_out_applies_once_per_account() { + let registry = FakeRegistry::with(&[(Scope::Machine, "optout", "11/8/2026 10:12:33")]); + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); + assert_eq!( + registry.applied(Scope::Machine).as_deref(), + Some("11/8/2026 10:12:33") + ); + // The next launch finds the stamp applied and does nothing, so a later dashboard enable stays. + assert_eq!(launch(®istry, true), (Outcome::NothingPending, false)); + } + + #[test] + fn a_new_stamp_from_a_later_install_applies_again() { + let registry = FakeRegistry::with(&[(Scope::Machine, "optout", "s2")]); + registry.set_applied(Scope::Machine, "s1"); + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); + assert_eq!(registry.applied(Scope::Machine).as_deref(), Some("s2")); + } + + #[test] + fn send_from_any_scope_never_acts_so_a_decline_is_never_flipped() { + for choices in [ + vec![(Scope::Machine, "send", "s1")], + vec![(Scope::User, "send", "s1")], + vec![(Scope::Machine, "send", "s1"), (Scope::User, "SEND", "s2")], + vec![(Scope::Machine, "", "s1"), (Scope::User, " Send ", "s2")], + ] { + let registry = FakeRegistry::with(&choices); + // Nothing runs: the bridge only ever runs `ocx telemetry disable`, and here not even that. + assert_eq!( + launch(®istry, true), + (Outcome::NothingPending, false), + "{choices:?}" + ); + assert_eq!(registry.applied(Scope::Machine), None); + assert_eq!(registry.applied(Scope::User), None); + } + } + + #[test] + fn an_unrecognized_value_counts_as_an_opt_out() { + for value in ["off", "0", "opt-out", "OptOut", " optout "] { + let registry = FakeRegistry::with(&[(Scope::User, value, "u1")]); + assert_eq!( + launch(®istry, true), + (Outcome::Applied, true), + "{value:?}" + ); + } + } + + #[test] + fn machine_and_per_user_opt_outs_are_both_honoured_machine_first() { + let registry = FakeRegistry::with(&[ + (Scope::User, "optout", "u1"), + (Scope::Machine, " OptOut ", "m1"), + ]); + let pending = pending_opt_outs(®istry); + assert_eq!( + pending + .iter() + .map(|choice| choice.scope) + .collect::>(), + vec![Scope::Machine, Scope::User] + ); + // One persisted decline covers both, and both stamps are recorded. + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); + assert_eq!(registry.applied(Scope::Machine).as_deref(), Some("m1")); + assert_eq!(registry.applied(Scope::User).as_deref(), Some("u1")); + } + + #[test] + fn a_later_install_recording_send_leaves_an_applied_decline_alone() { + // An opt-out from an earlier install was applied for this account ... + let earlier = FakeRegistry::with(&[(Scope::Machine, "optout", "s1")]); + assert_eq!(launch(&earlier, true), (Outcome::Applied, true)); + // ... then an upgrade where someone checked the box records send with a new stamp. + let later = FakeRegistry::with(&[(Scope::Machine, "send", "s2")]); + later.set_applied(Scope::Machine, "s1"); + // Nothing runs, so the saved decline stays, and the applied stamp is not touched. + assert_eq!(launch(&later, true), (Outcome::NothingPending, false)); + assert_eq!(later.applied(Scope::Machine).as_deref(), Some("s1")); + } + + #[test] + fn a_per_user_opt_out_applies_when_the_machine_choice_is_send() { + let registry = FakeRegistry::with(&[ + (Scope::Machine, "send", "m1"), + (Scope::User, "optout", "u1"), + ]); + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); + assert_eq!(registry.applied(Scope::Machine), None); + assert_eq!(registry.applied(Scope::User).as_deref(), Some("u1")); + } + + #[test] + fn an_applied_machine_stamp_does_not_hide_a_new_per_user_opt_out() { + let registry = FakeRegistry::with(&[ + (Scope::Machine, "optout", "m1"), + (Scope::User, "optout", "u1"), + ]); + registry.set_applied(Scope::Machine, "m1"); + let pending = pending_opt_outs(®istry); + assert_eq!( + pending + .iter() + .map(|choice| choice.scope) + .collect::>(), + vec![Scope::User] + ); + } + + #[test] + fn a_stamp_is_recorded_only_after_the_decline_persisted() { + let registry = FakeRegistry::with(&[(Scope::Machine, "optout", "m1")]); + assert_eq!(launch(®istry, false), (Outcome::NotPersisted, true)); + assert_eq!(registry.applied(Scope::Machine), None); + // The next launch tries again. + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); + } + + #[test] + fn an_unrecorded_stamp_is_reported_and_retried_next_launch() { + let mut registry = FakeRegistry::with(&[(Scope::Machine, "optout", "m1")]); + registry.refuse_marks = true; + assert_eq!(launch(®istry, true), (Outcome::AppliedUnmarked, true)); + registry.refuse_marks = false; + assert_eq!(launch(®istry, true), (Outcome::Applied, true)); + } + + #[test] + fn no_choice_at_all_runs_nothing() { + let registry = FakeRegistry::default(); + assert_eq!(launch(®istry, true), (Outcome::NothingPending, false)); + } + + #[test] + fn only_a_clean_exit_counts_as_persisted() { + assert!(decline_persisted(Some(0))); + for code in [None, Some(1), Some(2), Some(-1)] { + assert!(!decline_persisted(code), "{code:?}"); + } + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index fdc10147b1d..2b17ef86d54 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod exit; mod first_run; mod formatting; mod identity; +mod installer_telemetry; mod logging; mod login_autostart; // The judgment is portable so Linux CI runs its fixture parity tests; only macOS reads launchd. diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs index e601498fe0f..f055f1e6020 100644 --- a/desktop/src-tauri/src/sidecar.rs +++ b/desktop/src-tauri/src/sidecar.rs @@ -198,13 +198,17 @@ pub fn start( .map_err(|error| error.to_string())? .join("gui") .join("dist"); - let command = app + let mut command = app .shell() .sidecar("ocx") .map_err(|error| error.to_string())? .args(["start", "--port", &endpoint.port.to_string()]) .env("OPENCODEX_GUI_DIST", gui_dist) .env(SUPERVISED_ENV, "1"); + // The installer's opt-out could not be saved: keep this session from sending anything. + if crate::installer_telemetry::sidecar_env_opt_out() { + command = command.env("OCX_TELEMETRY", "0"); + } let (events, child) = crate::process_spawn::with_spawn(|| command.spawn()).map_err(|error| error.to_string())?; let watch = watch.for_child(child.pid()); diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index 0b3578e05ce..d7a5e86b620 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -809,6 +809,9 @@ async fn run(app: &AppHandle, started: Instant) { ); report(app, started, Phase::Resolving, None); + // SignPath's installation-option condition: an opt-out chosen in the Windows installer is + // saved into the user's config before any proxy is resolved, attached to or started. + crate::installer_telemetry::apply(app, deadline).await; // D5: the shell no longer resolves the home, the port or liveness. It asks the bundled CLI, // which owns the tuned probe budgets that exist because a shell-side reimplementation answered // "nobody is listening" twice and started duplicate proxies. The call is inside the sequence, so diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 892d5c37df0..48314474370 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.99.0", + "version": "2.100.1", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", @@ -52,7 +52,13 @@ "type": "downloadBootstrapper" }, "wix": { - "language": "en-US" + "language": "en-US", + "fragmentPaths": [ + "wix/telemetry-choice.wxs" + ], + "componentGroupRefs": [ + "OcxTelemetryInstallerChoice" + ] } }, "linux": { diff --git a/desktop/src-tauri/update-trust-root.json b/desktop/src-tauri/update-trust-root.json new file mode 100644 index 00000000000..94bcccfa837 --- /dev/null +++ b/desktop/src-tauri/update-trust-root.json @@ -0,0 +1,38 @@ +{ + "schema": "ocx.update.root/1", + "revision": 1, + "expiresAt": "2028-11-07T00:00:00Z", + "rootKeys": [ + { + "keyId": "eae4c0180e8ca39a844b3c0e1ec28842261e85628d6dee77ceff5dd081853799", + "algorithm": "minisign-ed25519-blake2b512/1", + "publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFDNzZCMDg0NkVCRUJGODEKUldTQnY3NXVoTEIyckhRUXJMOXJRUDR0aHQ2L3pLVHVweXFSc1lzS24vWDNiSUJ5MXJIZmpyb2sK" + } + ], + "rootThreshold": 1, + "releaseKeys": [ + { + "keyId": "eae4c0180e8ca39a844b3c0e1ec28842261e85628d6dee77ceff5dd081853799", + "algorithm": "minisign-ed25519-blake2b512/1", + "publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFDNzZCMDg0NkVCRUJGODEKUldTQnY3NXVoTEIyckhRUXJMOXJRUDR0aHQ2L3pLVHVweXFSc1lzS24vWDNiSUJ5MXJIZmpyb2sK", + "channels": [ + "latest", + "preview", + "nightly" + ], + "roles": [ + "release", + "tree", + "desktop-artifact", + "standalone-artifact", + "guardian-artifact" + ] + } + ], + "releaseThreshold": 1, + "channels": [ + "latest", + "preview", + "nightly" + ] +} diff --git a/desktop/src-tauri/wix/telemetry-choice.wxs b/desktop/src-tauri/wix/telemetry-choice.wxs new file mode 100644 index 00000000000..56b36c19f40 --- /dev/null +++ b/desktop/src-tauri/wix/telemetry-choice.wxs @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + OCX_TELEMETRY_CHOICE <> "" AND OCX_TELEMETRY_CHOICE ~<> "send" + + OCX_TELEMETRY_CHOICE <> "" AND OCX_TELEMETRY_STAMP = "" + OCX_TELEMETRY_CHOICE = "" AND OCX_TELEMETRY_PREVIOUS_CHOICE <> "" + OCX_TELEMETRY_STAMP = "" AND OCX_TELEMETRY_PREVIOUS_STAMP <> "" + + OCX_TELEMETRY_STAMP <> "" + + OCX_TELEMETRY_CHOICE = "" + + OCX_TELEMETRY_CHOICE ~= "send" + + + + + + + + + + + + + + + + + + + + + Read the full privacy policy]]> + + + + + 1 + + + OCX_TELEMETRY_SEND = "1" + NOT (OCX_TELEMETRY_SEND = "1") + OCX_TELEMETRY_CHOICE ~<> OCX_TELEMETRY_PREVIOUS_CHOICE + OCX_TELEMETRY_CHOICE ~= OCX_TELEMETRY_PREVIOUS_CHOICE AND OCX_TELEMETRY_CARRIED_STAMP <> "" + 1 + + + 1 + + + + NOT Installed + NOT Installed + + + diff --git a/devlog/_plan/261022_child_home_account_move/000_design.md b/devlog/_plan/261022_child_home_account_move/000_design.md new file mode 100644 index 00000000000..fae78e7cef9 --- /dev/null +++ b/devlog/_plan/261022_child_home_account_move/000_design.md @@ -0,0 +1,313 @@ +# OCX-4: moving provider accounts from a Remote Link Child to its Home + +Status: design only, open for owner decisions (section 11). No product code lands with this note. + +Requested by the GOAT hub design (goat-si/goat, `devlog/_plan/261021_goatclaw_hub/000_design.md` §1.8 and +brief OCX-4): "Optional, per-account consent on the Home dashboard, over the link and never through another +program. The Child keeps its copy until the person removes it." GOAT's hub switch (H7) depends on it. + +## 1. What exists today + +- A linked Child sends every model request to its Home. Its own providers and accounts stay on disk, labelled + local and inactive ([Remote Link](../../../structure/remote-link.md#child-management-relay-and-home-codex-settings)). + Account commands on a Child read the Home and refuse writes, so a Child has no way to manage its local + accounts through the normal account surface while linked. +- The link is one-directional at the HTTP layer. Whichever side opened SSH, the Child is the HTTP client and the + Home's hub-link listener is the server. Requests are admitted by three predicates: the data plane + (`src/link/routes.ts`), the read-only management table (`src/link/management-routes.ts`), and the single + Child parity report `POST /v1/link-management/child-parity` (`src/link/child-parity.ts`), whose only effect + is an in-memory entry bound to the key's own link. No Child-to-Home write reaches a durable store. +- Every relayed request runs on one socket proven by the relay-auth challenge (`src/link/relay-auth.ts`, + `src/client/link-relay-transport.ts`) with a one-use session nonce + (`src/server/index/link-relay-sessions.ts`). SSH owns host authentication and encryption. +- ADR-6032 keeps provider credentials off the link: the relay strips Bearer, `x-api-key`, `api-key`, + `x-goog-api-key` and similar headers before attaching the link key. This feature deliberately carries + credentials across the link, so it needs its own decision record (section 10). +- Account stores on a machine: + - `/auth.json`: multiauth OAuth, `provider -> { activeAccountId, accounts[] }`. + - `/codex-accounts.json`: the hardened Codex pool store (main plus added accounts). + - Provider API keys and key pools in `config.json`, optionally moved to the OS keychain + (`src/providers/key-store.ts`). + - Accounts this machine does not own: Orca-linked Codex accounts (a source reference, refresh owned by Orca, + see [Orca import](../../../structure/codex-home.md#orca-source-owned-account-import)), the Claude CLI sign-in, + the native Codex sign-in under `$CODEX_HOME`, and cloud credential files such as GCP ADC. +- Quota and cooldown state: + - API key cooldowns are process-local (`src/providers/api-key-cooldowns.ts`). + - Per-account provider quota survives restarts in `provider-account-quota-cache.json` with a six-hour + discard age (`src/providers/account-quota-disk.ts`); the Codex pool keeps `StoredAccountQuota` per account. + - Auto-pause and manual pause are durable metadata on the account; refusal counters are process-local + ([Account Auto-Pause](../../../structure/account-auto-pause.md)). +- The closest precedent for bringing accounts into a store is the Orca import: offline preview, identity + deduplication against main, configured and orphan credentials, and retry that completes only its own + untouched pending record. + +## 2. Goal and non-goals + +The person can take an account that is signed in on a Child and make it part of the Home's pool, one account +at a time, approving each on the Home dashboard. The credential travels only inside the existing link. The +Child keeps its copy, marked as moved, until the person removes it there. + +Out of scope for OCX-4: + +- Moving accounts from Home to Child. +- Moving provider definitions, base URLs, headers, routing profiles or combos. Only credentials move, and only + into providers the Home already has (section 4). +- Any SSH exec, `scp`, remote `ocx` invocation carrying a secret, export file or clipboard path. +- Automatic moves. Nothing moves without a decision on the Home for that specific account. + +## 3. Flow + +The Child initiates because it is the only side that can send requests over the link. The Home decides. + +```mermaid +sequenceDiagram + participant P as Person + participant C as Child + participant H as Home + P->>C: "Offer accounts to Home" (Child dashboard, paired session) + C->>H: POST offer (metadata only: provider, masked label, identity digest, payload digest) + H-->>P: Home dashboard lists the offer, one row per account + P->>H: Approve or decline each account (paired session) + C->>H: GET offer state (poll while the offer is open) + H-->>C: per-item decision + Home sealing key + C->>H: POST item payload, sealed, for approved items only + H->>H: verify digest and state, validate, dedupe, commit per item, audit + H-->>C: per-item result + C->>C: mark committed items "moved to Home", stop using them, audit + P->>C: Remove the local copy when ready +``` + +States of one offered item on the Home: + +```mermaid +stateDiagram-v2 + [*] --> Offered + Offered --> Declined: person declines + Offered --> Expired: no decision within the offer lifetime + Offered --> Approved: person approves + Approved --> Committing: matching payload arrives + Approved --> Expired: no upload within the upload window + Committing --> Committed: store write and audit succeed + Committing --> Failed: validation, dedupe or write fails + Failed --> Approved: retry allowed while the window is open + Committed --> Undone: person undoes on Home + Declined --> [*] + Expired --> [*] + Committed --> [*] + Undone --> [*] +``` + +### 3.1 Offer (Child to Home, no secrets) + +`POST /v1/link-accounts/offers` on the Home's hub-link listener, admitted by a new fourth predicate next to the +data plane, the management table and the child-parity report. Like child-parity, admission binds the request to +the link key's own link and caps the body size; unlike it, an accepted offer is durable. The body carries an +`offerId` the Child generates and up to a small fixed number of items, each with: + +- `itemId`, `provider` (the Home provider name it targets), `kind` (`codex-pool`, `oauth`, `api-key`); +- the masked identity the dashboards already show (for example a masked email or the last four key characters); +- `identityDigest`: SHA-256 over the provider name plus the stable upstream identity (account id, subject, + or the key itself), which lets the Home detect duplicates without seeing the secret; +- `payloadDigest`: SHA-256 over the canonical credential payload the Child will send; +- optional quota hints (section 6). + +The Home stores the offer in memory and in `/link/account-offers.json` (0600, metadata only) so a +restart does not lose a pending decision. It rejects offers from links without the feature, offers for +providers the Home does not have, and more than a bounded number of open offers per link. + +### 3.2 Consent (Home dashboard) + +Remote Link on the Home shows "Accounts offered by " with one row per item: provider, masked +identity, whether the Home already has the same identity, and the quota hint if one was sent. Each row has +Approve and Decline. There is no "approve all" in v1 (decision D3). + +Approving requires the operator-paired dashboard session, the same rule as `POST /api/link/join`. A +credentiallessly bootstrapped loopback session and a Tailscale identity session cannot approve, because any +local process can mint the former. The Home CLI may list offers but cannot approve them (decision D4). + +Approval binds to the exact `payloadDigest` and `identityDigest` that were shown. It opens a short upload +window for that item (proposal: ten minutes). + +### 3.3 Upload (Child to Home, sealed) + +The Child polls `GET /v1/link-accounts/offers/{offerId}` while its offer is open. For each approved item the +Home returns the decision and a per-offer X25519 public key it generated when the offer arrived. The Child +encrypts each approved payload to that key (X25519 + HKDF + AES-256-GCM, additional data = +`linkId | offerId | itemId | payloadDigest`) and sends it with +`POST /v1/link-accounts/offers/{offerId}/items/{itemId}`. The private key never leaves Home memory and is +dropped when the offer closes. Sealing is defense in depth on top of SSH and relay auth (decision D2). + +The Child sends nothing for declined or undecided items. A payload is never written to disk on either side +before it reaches the destination store's existing writer. + +### 3.4 Commit (Home) + +For one item, under `withConfigMutationLockSync`: + +1. Check the item is `Approved`, inside its window, on the same link id and current link key, and the + decrypted payload hashes to the approved `payloadDigest`. +2. Parse the payload strictly with the same validators a local login or key add uses. Unknown fields fail. +3. Deduplicate by identity against every Home credential, including main, configured and orphan identities + (the Orca import rule). A duplicate is refused as `already_on_home` and never overwrites the Home's copy + (decision D5 covers replacing a Home copy that needs reauthentication). +4. Write through the owning store's writer (`auth.json` add-account path, the Codex pool store, or the key + pool), tagged with provenance `{ movedFrom: linkId, offerId, itemId, at }`. +5. Append the audit record, then mark the item `Committed`. + +Imported accounts arrive not active: an OAuth import never becomes the provider's `activeAccountId`, and a +Codex pool import starts validation-pending, as Orca imports do today. The person can make it active on the +Home like any other account (decision D6). + +### 3.5 After commit (Child) + +The Child records each committed item as moved: the account stays in its store, gains a `movedTo` marker +(Home name, link id, time), is excluded from selection and from proactive token refresh, and is shown in a new +"Moved to Home" list on the Child dashboard with a Remove action. Removing is local-only and is the only thing +that deletes the Child's copy. OpenCodex never removes it on its own, and neither does GOAT. + +Because account commands on a linked Child target the Home, removal needs a local-scoped surface: the Child +dashboard list, and `ocx link moved-accounts list|remove ` on the Child. + +## 4. What can move + +| Account | Moves in v1 | Why | +| --- | --- | --- | +| OAuth multiauth account in `auth.json` (Anthropic instances, Kiro, Antigravity, xAI, Cursor, Kimi, ...) | Yes | Owned by OpenCodex, has an add-account path on the Home | +| Codex pool account in `codex-accounts.json` (added accounts) | Yes | Owned by OpenCodex | +| API key in a provider key pool, including a keychain-held key the Child can read | Yes (D7) | Owned by OpenCodex; the Home stores it in its own key store | +| Codex main account mirrored from `$CODEX_HOME/auth.json` | No (D8) | Owned by Codex on that machine | +| Orca-linked Codex account | No | A local source reference; Orca owns refresh | +| Claude CLI sign-in, GCP ADC and other external credential files | No | Owned by another program | +| Provider config: base URL, headers, models, routing | Never | A credential can be redirected by its endpoint; see the security note | + +An item targets a provider that already exists on the Home with the same vendor and an endpoint the Home +already trusts. If the Home lacks the provider, the offer row says so and cannot be approved until the person +adds the provider on the Home. + +## 5. Refresh-token lineage + +Keeping a copy on the Child means two machines hold the same refresh token. Several upstreams rotate refresh +tokens; ChatGPT, for example, reports `refresh_token_reused` (`src/codex/chatgpt-refresh-failure.ts`). Whichever +machine refreshes first invalidates the other's copy. The design handles this by making the Child's copy +dormant at commit: excluded from selection and from proactive refresh (section 3.5). While the Child stays +linked, its local accounts are already inactive, so the marker matters most if the Child later disconnects +and runs standalone. Resuming a moved copy on a standalone Child needs an explicit action that warns it may +sign the Home out of that account (decision D1). + +Implementation must confirm that no proactive refresh runs for a linked Child's inactive accounts today; this +note does not prove it. + +## 6. Quotas, cooldowns and pauses + +Upstream quota belongs to the account, not the machine, so an exhausted window on the Child is still exhausted +on the Home. Local counters are not facts about the account. + +| State on the Child | On the Home after the move | +| --- | --- | +| API key cooldowns (process-local) | Not carried. The Home starts clean; the first 429 re-establishes a cooldown. | +| Auto-pause refusal counters (process-local) | Not carried. | +| Durable pause (manual or auto-paused) | Carried: the account arrives paused with the same reason and needs explicit resume on the Home. | +| Persisted quota windows (Codex `StoredAccountQuota`, provider account quota cache) | Carried only as a hint: unscoped windows whose reset is in the future, stamped `source: "transferred"`, discarded at reset or after the existing six-hour age, whichever comes first. They order selection and never mark the account exhausted on their own (D9). | +| Quota history samples | Not carried. The Home's history for the account starts at the move. | +| Anthropic reset grants and other ledgers | Not carried. | + +The quota pool read (`/api/quota-pool`) shows a transferred hint with source `estimated` until the Home +measures the account itself. This needs a GOAT contract check before `ocx.quota-pool/1` emits it. + +## 7. Audit trail + +Both sides append to `/link/account-moves.jsonl` (0600, append-only, bounded by rotation). Each line: +time, link id, offer id, item id, provider, kind, masked identity, `identityDigest`, event, actor (session kind, +never a token), and a closed result code. Events: `offered`, `approved`, `declined`, `expired`, `uploaded`, +`committed`, `failed:`, `undone`, `child_marked_moved`, `child_removed`, `child_resumed`. + +No line carries a token, key, refresh token, full email or `payloadDigest`. `bun run privacy:scan` gains a +fixture for the format. + +The Home will expose the audit through `GET /api/link/account-moves` on its dashboard. The Child will expose +its own. + +## 8. Failure and rollback + +Each item commits on its own. A batch is a convenience for the person, not a transaction. + +| Failure | Result | +| --- | --- | +| Link drops before the offer reaches the Home | Child keeps the offer local and resends with the same `offerId`; the Home deduplicates by offer id. | +| Link drops after approval, before upload | Item stays `Approved` until its window ends; the Child uploads when the link returns. | +| Link drops during upload | The Child does not know the outcome. It re-polls; the Home's item state says `Committed` or still `Approved`. A second upload of a committed item answers `already_committed` and changes nothing. | +| Home crashes during commit | The store writers are atomic, and commit order is store write, audit, state. On restart, an `Approved` item whose identity already exists with this item's provenance is promoted to `Committed`; anything else stays `Approved` and can be retried. | +| Validation, dedupe or provider-missing failure | Item goes to `Failed` with a closed code; the Child's copy is unchanged and not marked moved. | +| Link revoked or key rotated mid-offer | Every open offer on that link expires; uploads are refused. Committed items stay. | +| Child never learns about a commit | The Child's copy stays active-capable rather than dormant; the next poll or the Home audit view corrects it. This is the safe direction because nothing is lost. | + +Rollback after commit is **Undo** on the Home row, available while the account still carries this move's +provenance. It removes the Home copy through the normal account deletion path and audits `undone`. Because the +Child kept its copy, undo loses nothing. The Child sees `undone` on its next poll and clears the `movedTo` +marker, so its copy becomes usable again (subject to section 5: if the Home already refreshed a rotating token, +the Child's copy may need a new sign-in, and the row says so). + +## 9. GOAT hub switch (H7) + +H7 moves the hub from the current Home (A) to a new Home (B), keeping A as the rollback until B is proven. +OCX-4 supplies its `PoolMoving` step. Two account sources exist: + +1. Accounts on Children. When a Child relinks to B in `Relinking`, it can offer its local accounts to B with + this flow, exactly as any Child would. +2. Accounts on A itself, which is the main pool. A is a Home, so it has no client link to B. H7 needs A to + offer to B over a link where A is the HTTP client. This design proposes an account-source link: a + Child-initiated link from A to B that enrolls through the normal probe, host confirmation and apply path, + uses relay auth, admits only the `/v1/link-accounts` table, and does not switch A's own client traffic or + connection state. A keeps serving its Children throughout (decision D10). + +H7 drives the steps through OpenCodex surfaces and never handles a credential: + +- On A (or a Child): `ocx link offer-accounts --link-id [--provider ...]` creates the offer. GOAT may run + it through its existing runner; the command prints offer and item ids only. +- On B: the person approves each account on B's dashboard. GOAT shows a decision item that links there; it does + not approve on the person's behalf, and the approval route refuses every non-paired session. +- GOAT reads progress from `GET /api/link/account-offers` and `GET /api/link/account-moves` (metadata only) + to decide when `PoolMoving` is done. +- `RolledBack`: GOAT asks the person to Undo on B, or leaves B's copies in place; A still holds every account, + dormant only if A marked them moved. On rollback A's markers are cleared through the same undo signal. +- `Committed`: A's copies stay on A, marked moved. The person removes them from A's dashboard when satisfied, + as §1.8 of the GOAT design already says. + +The `/readyz` feature `link-account-move/1` ([Runtime](../../../structure/runtime.md), `src/remote/features.ts`) +will let GOAT and the Child check support on both ends before offering. + +## 10. Implementation outline (for the follow-up PRs) + +1. Decision record under Remote Link that extends ADR-6032: credentials may cross the link only as a sealed + account-move payload on the account-move table, never as forwarded request headers. +2. `src/link/account-move-routes.ts` (table, admission), Home offer book and commit, Child offer client and + `movedTo` marker, sealing helper, audit writer. +3. Home dashboard offer panel and Child "Moved to Home" list (GUI PR with screenshots). +4. CLI: `ocx link offer-accounts`, `ocx link account-offers` (Home, read-only), `ocx link moved-accounts` (Child). +5. Account-source link kind for H7 (only if D10 is accepted). +6. `structure/remote-link.md` section, `structure/quota-pool.md` for transferred hints, docs-site Remote Link + guide. + +Tests: a real two-listener link fixture with isolated homes covering offer, approval refusal for loopback and +Tailscale sessions, digest mismatch, replay of a committed upload, link revocation mid-offer, crash recovery +between store write and state, dedupe, undo, the Child dormant marker, and no secret in any audit or log line. + +## 11. Decisions for the owner + +| ID | Question | Proposal | +| --- | --- | --- | +| D1 | What happens to the Child's copy after commit | Keep it, dormant (no selection, no refresh) until the person removes or explicitly resumes it | +| D2 | Seal payloads on top of SSH and relay auth | Yes, per-offer X25519 key held in Home memory | +| D3 | Allow "approve all" for an offer | No in v1; one decision per account | +| D4 | Can the Home CLI approve | No; dashboard paired session only. CLI lists offers | +| D5 | Same identity already on the Home | Refuse as `already_on_home`; later option to replace only when the Home copy needs reauthentication | +| D6 | Imported account becomes active | Never automatically; validation-pending or inactive | +| D7 | Include API keys (and keychain-held keys) in v1 | Yes for key pools; keychain keys only when the Child can read them without prompting | +| D8 | Include the Codex main account | No; it belongs to Codex on that machine. Revisit only with a Codex-owned export | +| D9 | Carry quota windows | Only as hints with future resets, never as exhaustion; durable pauses carried as pauses | +| D10 | H7 source for the old Home's own pool | Account-source link (offer-only, no traffic switch). Alternatives: make A a full Child of B (breaks A serving its Children) or re-sign every account on B (no move at all) | +| D11 | Offer and upload windows | Offer lifetime 24 h, upload window 10 min after approval | +| D12 | Who may start an offer on the Child | Child paired dashboard session or Child CLI with its admin token | + +The security analysis is in [010_security.md](010_security.md). + diff --git a/devlog/_plan/261022_child_home_account_move/010_security.md b/devlog/_plan/261022_child_home_account_move/010_security.md new file mode 100644 index 00000000000..b22f7144f00 --- /dev/null +++ b/devlog/_plan/261022_child_home_account_move/010_security.md @@ -0,0 +1,81 @@ +# OCX-4 security note: Child-to-Home account move + +This is a threat model for a design that is not built. It describes no existing weakness. + +## Assets and trust today + +The assets are provider credentials: OAuth refresh and access tokens, Codex pool tokens and API keys. Today each +stays on the machine that signed in, and ADR-6032 keeps them off the link. After this change one credential +can exist on two machines, and the link carries it once, sealed. + +The Home already sees everything a Child sends upstream, because it relays every request. A compromised Home +can already read a linked Child's prompts. Giving it the Child's credentials adds the ability to use those +accounts directly. That is the purpose of the feature and the reason consent sits on the Home, per account. + +## Threats + +### A compromised Child + +A Child under an attacker's control holds the link key and can pass relay auth. What it can try: + +- **Plant an attacker-owned account in the Home pool.** If the Home routes the person's traffic through it, + the attacker's upstream account receives the person's prompts. Defenses: nothing moves without a per-account + approval on a paired Home session; the row shows the masked identity so the person can recognize their own + account; imports never become active automatically (D6); the audit names the source link. Residual risk: a + person who approves an identity they do not recognize. The UI should say plainly that approving lets the + Home send requests through this account. +- **Redirect a credential.** A key paired with a malicious base URL would leak every request. Defense: only + credentials move; the item binds to an existing Home provider and that provider's endpoint, and the payload + parser rejects endpoint, header and model fields. +- **Overwrite a Home account.** Defense: dedupe refuses an existing identity and never overwrites (D5). +- **Exhaust the Home.** Bounded open offers per link, bounded items per offer, bounded payload size, strict + parsing, and a rate limit on offer creation. Offers carry no secrets, so storing them costs little. +- **Approve its own offer.** The approval route is a Home dashboard route; the hub-link listener never admits + it, and a loopback-bootstrapped session on the Home cannot approve. +- **Lie in quota hints.** Hints only order selection and expire; they never mark an account exhausted or + paused on the Home. A durable pause arriving from the Child makes the account paused, which is a + denial-of-service on that one account only and is visible on the row before approval. + +### Replay + +- A captured relay request cannot be replayed on another socket: relay auth binds the request to one proven + socket with a one-use nonce. +- A repeated upload for a committed item answers `already_committed` and changes nothing; item state moves + forward only. +- An upload must match the `payloadDigest` the person approved and arrive inside the upload window, on the + same link id and key. Rotation or revocation expires open offers. +- The sealing key is per offer and dropped when the offer closes, so an old sealed payload cannot be opened + later even if replayed into a new offer. + +### Partial transfer + +Each item commits on its own, through an atomic store writer, under the config mutation lock. A crash leaves +the item either uncommitted (retryable) or committed with provenance that restart reconciliation recognizes. +The Child's copy is untouched until it learns of the commit, so the failure direction is "the account exists in +both places", never "the account exists nowhere". + +### Interception on a machine + +SSH encrypts between machines. Relay auth proves the port's owner but, as the +[Remote Link doc](../../../structure/remote-link.md#connection-bound-relay-authentication) states, is not +encryption against an active local intermediary on the loopback port. Sealing payloads to a per-offer Home key +(D2) closes that gap for credentials: an intermediary sees only ciphertext bound to the offer and item. + +### Refresh-token reuse + +Two live copies of a rotating refresh token sign each other out. Making the Child's copy dormant at commit +avoids this in normal use; explicit resume on a standalone Child warns about it (D1). + +### Leakage through records + +Audit and offer files carry masked identities and digests only, with private permissions. Digests of OAuth +identities are over account ids or subjects. For a bare API key the identity digest is over the key, which is +high entropy, so the digest does not make the key cheaper to recover. No log line carries a payload, a token +or a full email. `privacy:scan` covers the new formats. + +## What stays out of reach + +- SSH exec, `scp`, export files and clipboard paths are never used. +- GOAT drives offers and reads progress but never receives a credential and cannot approve. +- A Home never pulls from a Child; there is no route by which a Home asks a Child for credentials. + diff --git a/devlog/_plan/261107_auto_update_parity/000_plan.md b/devlog/_plan/261107_auto_update_parity/000_plan.md new file mode 100644 index 00000000000..5fb9365c8af --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/000_plan.md @@ -0,0 +1,266 @@ +# Automatic update parity — roadmap draft + +OpenCodex already checks for desktop updates and has a guarded package update +orchestrator. This unit extends those owners into a user-controlled automatic +update experience, with the existing sidebar download control as its entry +point. The [inventory](001_inventory.md) separates observed behavior from gaps; +the [gap ledger](002_gap_ledger.md) assigns completion evidence to each one. +The [contract map](003_contracts.md) tracks proposed fields and their owners. +The roadmap's architecture reflection and independent plan audit are complete +for the snapshot recorded below. Each implementation unit remains a proposal +until its own current-base plan audit, tests and applicable independent security +review have passed; no update behavior is claimed to be implemented. + +## Loop specification + +- **Archetype and trigger:** satisfy-spec, scoped HOTL; the requested GOAT + parity work began on 2026-11-07. +- **Work class:** C4 for updater, signing, installation, release and authority + changes; the docs-only roadmap is independently audited before code. GOAT's + built-in discipline was read from the pinned reference revision at + `packages/provider-core/src/server/builtinSkills/goatDiscipline.ts:7`. +- **Goal:** signed, user-controllable updates for desktop and eligible linked + installations; useful progress, deferral and failure states in the sidebar. +- **Non-goals:** modify the user's installed app, services or running proxy; + merge pull requests; publish releases; broaden workflow triggers or + permissions; add optional-subsystem imports to core paths; add telemetry. +- **Verifier:** per-unit failing-first behavior tests, isolated homes and mock + feeds, typecheck and privacy scan, ownership and locale checks, UI screenshots, + exact-head required CI, and independent security review where applicable. + Commands and the source each observes belong in each completed decade plan. +- **Stop:** report each PR ready for coordinator integration only after its + exact-head checks and required reviews pass. Continue until every ledger gap + is closed or a concrete blocker is reported. +- **Memory:** this public unit and the bound Codexclaw goalplan; security + working notes belong in `.tmp/`, never in this directory. +- **Outcomes:** DONE requires the complete audited scope and evidence; + BLOCKED or NEEDS_HUMAN identify missing authority, supply or capability; + BUDGET_EXHAUSTED applies only to an actual bound. +- **Escalation and bounds:** report decisions outside the granted scope to the + coordinator. No token or wall-time bound stated; host limits apply. Keep load + moderate: no full root test suite. User authorized commits, pushes, PRs and + platform proof when needed, but not merge or release publication. A PR-branch + `lane=all` requires an explicit maintainer request on that PR; run it once on + the exact intended head, never redispatch because the head moved or rerun to + green, and cancel owned superseded runs. Ordinary full dispatches are batch + checks on `dev`, as required by `AGENTS.md`. + +## Draft dependency map + +1. **Signed supply and channel contracts.** Determine and implement a + verifiable artifact source for every automatic install surface. Extend + release metadata only when it can preserve existing signing and CI policy; + missing, invalid, or unverifiable signatures must refuse the automatic path. + Desktop stable/preview/nightly labels must correspond to feeds that actually + exist. npm provenance is a candidate for investigation, not yet a verified + consumer contract. Existing explicit package commands keep their current + behavior until a verified-supply migration ships. That migration should + retain their interfaces but may refuse operations that previously succeeded; + document the impact and supported alternatives for each manager. +2. **Automatic activation admission.** Extend the existing automatic restart + fence and handoff (`src/server/management/system-restart.ts:385`) with an + identity-bound, expiring update reservation that proves no work remains and + transfers atomically into shutdown. Release its temporary fence before shutdown waits + for temporary drains. A plain sample of activity is insufficient: the + existing terminal drain aborts turns after its deadline + (`src/server/lifecycle.ts:520`), and an unreleased temporary fence can make + shutdown wait on itself (`src/server/lifecycle.ts:131`). +3. **Desktop staged update engine.** Extend the Tauri owner to check, download + with progress, verify, retain a staged update, and apply through the existing + restart/drain and recovery path. Persist user controls and channel state; + account for stale checks, concurrent commands, unavailable feeds and failed + or interrupted installs. Signature verification stays mandatory. The exact + default is an audit decision; it must be justified against GOAT's defaults + and the user's control requirement. +4. **npm preparation and activation.** Extract verified npm preparation + from the existing transactional swap. Automatic paths + must verify every artifact they execute, including installer dependencies; + unsupported owners remain visible and never silently fall back. +5. **Sidebar and update panel.** Extend the existing session-scoped snapshot + and sidebar download button to express current, available, downloading, + ready and failed states; open a panel with channel, automatic download and + installation controls, release notes, restart and Later. Preserve the native + update-page command boundary and browser package-update behavior. Resolve the + footer version truncation and validate accessibility, locales and geometry. +6. **Linked-machine orchestration.** Reuse Home–Child link identity, pinned + sync and the orchestrated plan/apply path where their contracts fit, while + adding signed-target verification, device-side controls, compatibility and + idle admission, per-machine progress, and failure/recovery reporting. Treat + unavailable identity, incompatibility, unverified signatures and offline + machines as explicit non-success states. Do not equate catalog sync with an + installation update. +7. **Standalone installation support.** Add a signed, owner-bound standalone + adapter and platform-specific recovery after the common supply and admission + contracts exist; do not treat the current standalone refusal as closure. +8. **Child-initiated link transport.** Add a separately authorized Child-pull + update exchange for links without Home-to-Child SSH; keep the existing + management relay read-only and preserve destination consent. +9. **pnpm-owned installations.** Implement and prove the selected global + owner's offline signed preparation, manager-compatible transition and rollback + in `090`; unsupported layouts remain open until proven. +10. **Bun-owned installations.** Implement and prove signed offline + preparation, global metadata/bin ownership, activation and rollback in + `100`; do not treat an ordinary global add as a staged transaction. + +The later units extend the preceding integration contracts: `070` adds a +standalone owner to the linked update coordinator, `080` carries those owners +over the Child-initiated transport, `090` adds a pnpm adapter to both link +directions, and `100` reuses the resulting manager-stage interface for Bun. +Each still has its own local behavior and independently reviewable PR. This +dependency chain preserves the full goal even when an earlier owner is not yet +eligible for automatic activation. + +Each unit will receive a decade-numbered document with its exact file map, +before/after behavior, public contracts, acceptance scenarios and fresh +verifiers. Each produces a separate PR against `dev`; dependency and base +strategy will be resolved in the audited plan. Any updater, package installation, +release, signing or workflow PR requires a separate independent security PASS. +The owner requested no full root suite; record focused coverage and the CI +coverage left outstanding rather than describing an unrun suite as passing. + +## Delivery and base strategy + +Each unit is a separate PR whose base is `dev`, as requested. Do not create a +stack whose child targets another PR. After the docs-only roadmap PR and each +implementation PR reaches exact-head readiness, report its URL, head, checks and +reviews to the coordinator. The coordinator alone decides whether and when to +merge. Once the prerequisite is actually on `origin/dev`, fetch and verify that +head before starting the next unit on a fresh branch from it; revalidate that +unit's prewritten plan against the resulting code. If a prerequisite is not yet +integrated, watch the PR through GOAT and wait for the coordinator rather than +manufacturing a downstream base or merging it here. A PR description uses the +repository's Summary, Verification and Checklist template. GUI PRs link a +reviewable screenshot hosted through `pr-assets`, never committed to the PR +branch. Add `ci:full` when subprocesses, files read as data or generated +outputs require coverage the changed-test import graph cannot discover +(`MAINTAINERS.md:65`). Required CI must complete on the current head. + +For user-facing changes, compare the canonical English installation, +desktop-app and remote-link pages and their existing `fr`, `ja`, `ko`, `ru`, +`tr`, `zh-cn` and `zh-tw` translations; update every affected translation so +it does not contradict the English source. For any docs-site edit, run its +required `cd docs-site && bun install --frozen-lockfile && bun run build` +(`docs-site/AGENTS.md`). GUI has eleven locales and its own locale parity +checks. Review mapped `structure/` owners on each unit; `structure:check` +does not itself establish that the prose accurately describes new behavior. + +## Consultation and audit + +Architect task: `auto-update-parity-architect-01`, read-only, Codex +`gpt-6.1-sol` with xhigh reasoning. Proposal received 2026-11-07 at the pinned +revisions in `001_inventory.md`; the following dispositions are provisional until +the same architect reflects on the concrete plan and the independent audit passes: + +| Decision | Main disposition | +| --- | --- | +| AU-001 installer ownership | Accept: retain distinct native and package owners and their existing mutation gates. | +| AU-002 controls | Accept: separate check, download and installation policy and revalidate before effects; preserve manual activation as the proposed default. | +| AU-003 candidate | Accept with amendment: exact candidate and provenance must satisfy the user's mandatory signature requirement, not just registry SRI. | +| AU-004 preparation | Accept conditionally: staged handles stay owner-held; npm, pnpm and Bun support must be proven individually, not assumed. | +| AU-005 admission | Accept: add a separate reservation unit and explicitly prove the shutdown handoff cannot race admission or wait on its own temporary lease. | +| AU-006 recovery | Accept: distinguish installation from a healthy serving target, preserve Stop/Resume intent and suppress repeat failed targets. Assess GOAT probation parity explicitly rather than treating it as automatic proof. | +| AU-007 Child authority | Accept with amendment: diagnostics do not finish the requested goal; the roadmap must include separately authorized and security-reviewed remote binary updates, not defer them out of scope. Preserve `ocx.sync.fanout/1`. | +| AU-008 channel and rollout | Accept: derive supported feeds from actual signed publication and never invent a nightly or preview channel. Document rollout and policy defaults. | + +The same architect's reflection (`auto-update-parity-architect-reflection-01`) +returned **MISALIGNED**: supply/trust, activity fencing and stop-to-installer +handoff, manager-specific staging, remote authority and resumption, policy and +recovery, sidebar wire/cadence, and exact PR/test mapping were incomplete. The +unit docs subsequently addressed these findings as described below. In particular, the existing restart +route's handoff is not by itself an installer-stop handoff, and peer observations +are not authorization. + +The user's authorization for platform proof operates within the unchanged +`AGENTS.md` CI policy stated above. Subsequent architect reflection and the +independent roadmap audit are recorded below. + +The third reflection (`auto-update-parity-architect-reflection-03`) returned +**MISALIGNED** against the 14-file snapshot: executable manager transitions, +update-stop authority and complete activity fencing, platform recovery actor, +signing/rotation, destination authority and Child-pull transport, future-target +compatibility, and nightly source/CI binding and shared-contract ownership needed +decisions and evidence at that point. The manager units now explicitly require isolated +pre-audit research; `003` specifies a candidate capability descriptor and +`010` owns shared parser/test-vector delivery before `020`. These amendments +were proposals awaiting the later reflections and independent audit below. + +The fourth reflection (`auto-update-parity-architect-reflection-04`) reviewed +snapshot `1498639d17aef1013f99e406fc8ae574588a8f3da558ca6b7a8c5c3456e54cc8` +and returned **MISALIGNED**. Its six material findings concerned bounded native +acquisition; retaining a verified stage and grouped notes; cross-process +admission/readback; platform guardian and remote Desktop handoff; +simultaneous Home/Child compatibility; and manager activation/recovery. The +main agent accepts these findings and has amended `003`, `010`, `020`, `030`, +`050`, `060`, `090` and `100` with candidate contracts and evidence from the +two completed isolated manager investigations. These amendments remain +unverified proposals at that point, not a security PASS. Subsequent architect +reflections and the independent roadmap audit are recorded below. + +Reflection 05 reviewed snapshot +`21bdadc2bc897481027b534a43f48de8704a97b1fb2eb11250c5edf04daa132f` +and returned **MISALIGNED**. The main agent accepted its six remaining +findings: installed channel provenance and browser policy, locale Back +navigation, quit/stopped-runtime health semantics, Windows app/installer +handoff, cross-runtime registry interoperability, and manager-generated output +authority. `003`, `010`, `020`, `030`, `040`, `050`, `090` and `100` now +describe proposed resolutions and falsifiable tests. The installed SQLite +dependency and Node/Bun broker are proposed new reviewed boundaries, not +existing capabilities. + +Reflection 06 reviewed snapshot +`cd9d7c20127a19fc08452c3203f9320ccdbe3d17a81d50aee942fa18d5145b5b` +and found the six preceding design amendments addressed, while requiring the +CI-policy wording above and the native-to-badge channel transport in `003` +and `050`. The main agent accepted both findings. The subsequent audit is +recorded below; implementation proofs, Rust 1.90 and Node 18 interop and the +platform matrix remain gates. + +The independent roadmap audit of that snapshot returned **GO-WITH-FIXES** with +two medium blockers: the desktop badge's old non-null channel DTO lacked an +unknown-provenance migration, and linked-update dashboard actions lacked an +end-to-end management and GUI contract. The main agent accepted both findings; +`003`, `050`, `060` and `080` now propose separate versioned badge and linked +dashboard DTOs, precise legacy/old-server behavior, authorized actions and +future regression tests. The same independent reviewer re-audited these +amendments as recorded below. + +The independent follow-up accepted the badge DTO correction and identified a +remaining Retry A→B request-correlation blocker plus a non-blocking warm-cache +failure presentation issue. The same architect independently identified the +Retry gap. The main agent accepted both; `060` and `080` now specify durable +request-ID readback and replay behavior, while `050` requires stale badge data +to be visibly unknown after a failed latest observation. The subsequent +architect reflection and independent re-audit are recorded below. + +## Roadmap audit disposition + +The same architect's reflection 09 returned **ALIGNED** and independent audit +round 03 returned **PASS** for the 14-file snapshot +`251f5fc6aebd2c28d3c88096caa3033266587c55db3b56f613180b9275350cf9`. +The review closed the badge DTO, linked dashboard action/readback and stale +cache findings; its final round found no new high or critical blocker. The +reviewer checked staged whitespace, the privacy scan, citation ranges and +the changed consumer paths. This disposition records a plan audit only: no +proposed update implementation, artifact signature, platform install, +security review or exact-head PR CI is claimed to have passed. + +Before a PR is marked ready, fetch the current `origin/dev` and revalidate the +inventory and implementation plans on its exact resulting base. The last +audited fetched base was `35d5010f502a8efd973d75c9e7a1b44eecfde777`; its +WebSocket turn admission attaches a workflow lease and its binary GUI switch +uses `role="switch"` and `aria-checked`. Later upstream changes need a new +impact assessment. Every implementation unit remains subject to its own +failing-first tests, required checks and independent security review where +applicable; this roadmap audit is not permission to skip those gates. + +The independent roadmap reviewer must receive the original goal and hard +constraints, the exact plan revision, and the pinned source revisions. The +review must challenge the full file and field chains, reachable negative cases, +cross-version compatibility, source-to-artifact verification, destination +authority, recovery and PR dependency order. It must verify existing commands +and identify candidate commands that do not yet exist or do not observe the +proposed change. Record each finding's acceptance or evidence-backed rebuttal, +amend the plan, and re-review with the same reviewer until blockers are closed; +an architect reflection is not the independent audit. Security-sensitive +working notes remain in `.tmp/`. The review packet will attach the code-review +and search skills and require a normalized verdict. diff --git a/devlog/_plan/261107_auto_update_parity/001_inventory.md b/devlog/_plan/261107_auto_update_parity/001_inventory.md new file mode 100644 index 00000000000..68b564a2088 --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/001_inventory.md @@ -0,0 +1,89 @@ +# Update parity inventory + +Baseline: OpenCodex `803d6c0b4dbb4823a9379e485e417f3f9e3d976c`; GOAT +`64e7198988b69941cd2eec5160fd734df9074302` (`origin/dev`). GOAT paths below +refer to that read-only Git revision. The independent architecture inventory +informed this comparison. The audit recorded in `000` covers an earlier named +snapshot; the current revision and its independent review remain open. + +| Surface | Existing evidence | Initial gap to investigate | +| --- | --- | --- | +| Desktop checks | OpenCodex checks after 30 seconds and every six hours (`desktop/src-tauri/src/updater.rs:448`). GOAT starts after 15 seconds and checks every four minutes (`apps/desktop/src/updates/DesktopUpdates.ts:69`, `:1036`, GOAT revision above). | Decide cadence and user controls; do not add a duplicate scheduler. | +| GOAT Electron integration | GOAT wraps the platform updater and installs a request deadline before first use (`apps/desktop/src/electron/ElectronUpdater.ts:90`, `:100`, `:120`, GOAT revision above); its test verifies lazy initialization and that deadline (`apps/desktop/src/electron/ElectronUpdater.test.ts:38`, `:46`). The Electron app wrapper exposes quit/relaunch and the before-quit-for-update event (`apps/desktop/src/electron/ElectronApp.ts:166`, `:174`, `:234`, GOAT revision above). Automatic-update tests cover pause, retry, reservation and busy-at-admission cases (`apps/desktop/src/updates/DesktopUpdates.automatic.test.ts:91`, `:116`, `:271`, `:652`, GOAT revision above). | Use bounded requests and verify equivalent behavior through Tauri's own owner; no Electron code is imported into OpenCodex. | +| Desktop download and install | OpenCodex downloads through the pinned updater, which verifies the signature, before its guarded restart and install; failed install can recover the runtime (`desktop/src-tauri/src/updater.rs:388`, `desktop/src-tauri/src/updater.rs:423`). | Expose progress and verified staged readiness while preserving signature verification and drain/recovery. | +| Desktop badge | The desktop snapshot includes a session, version, availability, timestamp and phase (`src/update/desktop-badge.ts:4`); the badge projection currently exposes availability and unknown but discards the phase (`src/update/desktop-badge.ts:74`). | Carry authoritative progress and failure states to the footer without treating missing or stale evidence as current. | +| Sidebar | `gui/src/components/sidebar-github-row.tsx:77` polls the badge and `:157` renders the existing download button; `gui/src/App.tsx:591` opens the native update page on desktop. The version is rendered at `gui/src/App.tsx:602` and deliberately ellipsized by `gui/src/design/bridge.css:429`. Native update commands require the trusted update page (`desktop/src-tauri/src/lib.rs:272` at the baseline above). | Make that button the requested stateful update-panel entry and resolve the reported truncated version; retain browser/package behavior and the existing native command origin boundary. | +| Package updates | The Node launcher imports the existing orchestrated plan/apply, installation ownership, transactional install and guarded runtime mechanisms (`bin/ocx.mjs:50`, `bin/ocx.mjs:69`). The CLI advertises latest/preview plans and token-bound apply (`bin/ocx.mjs:1322`). npm publication uses trusted publishing with generated provenance (`.github/workflows/release.yml:1152`). | Reuse ownership and guarded apply contracts, and require the requested publisher signature verification for update artifacts. Validate a concrete signed supply and its availability before treating provenance as a solution. Distinguish package and signed desktop-bundle supply. | +| Package notification | Version cache refresh interval is 20 hours, with a 40-hour maximum cache age (`src/update/notify.ts:19`); background refresh is detached when stale (`src/update/notify.ts:182`). The in-process scheduler honors `OCX_DISABLE_UPDATE_CHECK=1`, avoids unsupported installers and backs off after failed checks (`src/update/refresh-scheduler.ts:24`, `:52`, `:87`). | Preserve the existing opt-out and no-refresh semantics while adding explicit independent controls. | +| Browser dialog | The existing dashboard update dialog offers latest/preview, checks, errors, restart choice and an explicit update action (`gui/src/pages/dashboard-dialogs.tsx:29`). | Keep its installed-package semantics consistent with any shared state and copy. | +| Channels | OpenCodex's configured Tauri feed uses `latest.json` (`desktop/src-tauri/tauri.conf.json:68`); release verification generates that manifest only with the updater signing key and requires signatures in that case (`.github/workflows/release.yml:824`). npm uses `latest` and `preview` tags (`.github/workflows/release.yml:1051`). GOAT publishes stable and nightly feeds; its documented preview is a PR build, not a third published feed (`docs/internals/release-channels.md:19`, `docs/internals/release-channels.md:80`, GOAT revision above). | Establish actual artifact availability before presenting a channel as usable; never invent a nightly feed or treat a digest as a signature. | +| GOAT update states and policy | GOAT's state includes install policy, available and downloaded versions, release notes, download percent and retry information (`apps/desktop/src/updates/updateMachine.ts:18`, GOAT revision above). Its default downloads and restarts when local servers are idle **or on quit**; alternatives are install on quit and paused, with manual update still available (`docs/user/updating.md:120`, `:128`). WSL servers that cannot report idleness defer idle restart until quit or manual installation (`:136`). The coordinator disables electron-updater's implicit auto-download and install-on-quit and controls the sequence itself (`apps/desktop/src/updates/DesktopUpdates.ts:1398`); the paused policy blocks automatic downloads (`:620`). | Model the full state and user-controlled sequence without confusing library flags with the actual product policy. | +| Linked machines | Home fan-out sync selects and version-checks the Child executable before invoking sync (`structure/remote-link.md:9`, `src/link/ssh-argv.ts:281`); this is configuration sync, not evidence of binary update capability. OpenCodex already derives link compatibility from authenticated relay version and peer features (`src/link/compatibility.ts:71`, `src/remote/features.ts:44`). GOAT describes exact-build updates, idle gating and protocol-range compatibility (`docs/internals/remote-updates.md:19`, `:100`, GOAT revision above). GOAT's runtime supply documents HTTPS plus `SHA256SUMS`, without detached signatures (`docs/internals/remote-updates.md:163`, GOAT revision above). | Extend the existing compatibility and authorization owners for update capabilities rather than duplicating them; prove signed provenance and safe remote execution separately from sync. The user's signature requirement is stricter than the referenced runtime supply and remains mandatory. | +| Fan-out and orchestrator contracts | [PR #8100](https://github.com/lidge-jun/opencodex/pull/8100) merged to `dev` as `4948c04d9ced0cae90779ce03196088cc97aa906`. Current source advertises `sync.home-fanout/1` and `update.orchestrated/1` (`src/cli/capability-contracts.ts:1`). Fan-out waits for a current Home catalog, skips client-initiated/disconnected links, bounds concurrency to three and records per-Child results (`src/cli/sync-fanout.ts:99`). | Preserve both advertised contracts and avoid converting catalog sync into implicit binary installation; add a separately versioned update operation. | +| Server rollout and recovery | GOAT's server auto-update is on by default and separately disableable; stable/nightly remain on their channel and preview/dev/PR do not auto-update (`structure/decisions/ADR-0019-update-compatibility.md:50`, GOAT revision above). Stable waits 48 hours then rolls out over 72 hours, nightly rolls out over 12 hours without soak, and missing publication time is ineligible (`apps/server/src/cloud/autoUpdatePolicy.ts:79`). Busy activation retries every 10 minutes; a failed target is suppressed (`structure/decisions/ADR-0019-update-compatibility.md:57`, `:64`). A launcher trial needs exact-version and protocol health within 30 seconds, followed by 10 minutes' probation and owner-checked snapshot rollback (`:69`). | Assess each guarantee against OpenCodex's actual installation and service model; checksums alone must not be presented as publisher signature verification. | +| Isolated mock feed | GOAT's mock server serves files from a configured real root on localhost (`scripts/mock-update-server.ts:19`, `:111`, `:144`, GOAT revision above); its tests reject traversal and escaping symlinks (`scripts/mock-update-server.test.ts:23`, `:50`). Its release smoke uses a temporary root and validates merged manifests (`scripts/release-smoke.ts:216`, `:298`, GOAT revision above). | Build equivalent isolated fixtures for Tauri and package formats with test-only keys, bounded responses, failure injection and no access to real homes. GOAT's preview manifest smoke fixture is not evidence of a published preview feed. | +| Telemetry | GOAT updater code emits internal logs and spans (`apps/desktop/src/updates/DesktopUpdates.ts:485`, `:1232`, GOAT revision above). GOAT documents product usage and relay telemetry and an opt-out (`docs/user/telemetry.md:3`, `:23`, GOAT revision above); its desktop observability layer can export logs/traces to configured OTLP endpoints (`apps/desktop/src/app/DesktopObservability.ts:778`, `:803`, `:826`, GOAT revision above). This does not establish a dedicated updater analytics event. | Do not describe GOAT as categorically telemetry-free. The OpenCodex implementation must introduce no update telemetry; verify network destinations and privacy scan. | + +The coordinator described the desktop sidebar's existing footer and requested +states: current, available, downloading percentage, ready to restart and failed; +the panel should provide channel selection, automatic download and install +controls, release notes, restart and deferral. No screenshot is present on this +host. Defaults, supported channels, exact source changes and verification +remain decisions for the audited roadmap, not claims of implemented behavior. + +Baseline verifier discovery on 2026-11-07, before implementation: after +`bun install --frozen-lockfile` at the repository root and in `gui/`, +`bun test tests/update/update-desktop-badge.test.ts tests/update/update-badge.test.ts` +passed 17 tests. From `gui/`, +`bun test tests/sidebar-version-layout.test.ts tests/sidebar-rows.test.ts` +passed 12 tests. The first pair exercises badge source modules; the second pair +checks sidebar source and header styling, not rendered footer geometry. The +initial runs failed because the checkout had no installed dependencies; the +GUI test paths also need to be run from `gui/` or explicitly prefixed with `./`. +These baseline results do not prove any future change. + +Further baseline verifier discovery on 2026-11-07: +`bun test tests/clients/desktop-update-surface.test.ts tests/ci-workflows/release-desktop-scripts.test.ts` +passed 48 tests, including the existing desktop-page harness and release +signature verifier. `bun test tests/cli/sync-fanout.test.ts +tests/clients/link-peer-compatibility.test.ts` passed 22 tests, exercising +the existing fan-out classifier and link compatibility implementation. These +tests directly read or import their existing owners; they do not prove any +future signed package or remote-update design. + +`bun run structure:check` also passed at the baseline (exit 0). Its script +checks the `structure/` manifest, source ownership and invariant bindings +(`scripts/structure-ssot.ts:299`, `:518`, `:573`); it does not validate this +`devlog/` roadmap's correctness. Roadmap review is a separate human/independent +audit, and new structure files must be staged before rerunning that gate. +`bun run typecheck` passed at the baseline (exit 0; 2,326 files, no type +errors). This checks TypeScript, not Rust, rendered UI or this prose roadmap. +`bun run privacy:scan` passed after staging the draft unit, because that scanner +enumerates tracked/indexed files (`scripts/privacy-scan.ts:64`). Re-run it on +the final staged roadmap before publication. +`bun test tests/lab/core-lab-boundary.test.ts` passed 25 tests (exit 0); it +checks protected core import graphs and the synchronous Lab activation window. +`bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts` passed +29 tests (exit 0), including the repository membership oracle and expected +fixture comparison. It checks test placement and map consistency, not whether +the planned regression tests are adequate. +`bun scripts/file-size-ratchet.ts` passed (exit 0) on the baseline; it reads +the repository baseline and current file sizes without updating the caps +(`scripts/file-size-ratchet.ts:212`, `:224`). +The ratchet's general threshold is 2,000 lines and Rust is not among its +scanned extensions (`scripts/file-size-ratchet.ts:4`). At this baseline +`src/update/job.ts` has 1,986 lines and no per-file baseline entry, leaving +only 13 additional lines before it reaches the rejected threshold. Prefer a +sibling module for substantive additions; recheck current counts after each +prerequisite lands. A missing baseline entry does not mean a tracked file is +untracked by Git. + +The bundled desktop page is `desktop/ui/update.html`; its existing status, +check and install commands are guarded to that page in +`desktop/src-tauri/src/lib.rs:272` and `desktop/src-tauri/src/window.rs:115` at +the baseline above. +`tests/clients/desktop-update-surface.test.ts:38` exercises the page and its +command boundary. The dashboard receives desktop state via +`src/server/management/sidebar-routes.ts:137`, whose store strictly validates +the snapshot field set (`src/update/desktop-badge.ts:31`); any additional state +must be designed end to end, including older shells and stale sessions. diff --git a/devlog/_plan/261107_auto_update_parity/002_gap_ledger.md b/devlog/_plan/261107_auto_update_parity/002_gap_ledger.md new file mode 100644 index 00000000000..121d0a07a97 --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/002_gap_ledger.md @@ -0,0 +1,29 @@ +# GOAT versus OpenCodex update gap ledger + +Evidence and source revisions are in [001_inventory.md](001_inventory.md). +Every row is **open** until its named unit ships and its acceptance evidence is +recorded. A baseline check or draft plan does not close a gap. + +| Requested capability | Existing OpenCodex surface | Gap owner and closure evidence | +| --- | --- | --- | +| Background discovery and cadence | Package cache scheduler and Tauri six-hour checks | `030`, `040`: policy-controlled, bounded checks and failure/backoff tests; user can disable them. | +| Stable, preview and nightly selection | npm latest/preview; Tauri latest feed | `010`, `030`, `040`, `050`: signed channel metadata, actual publication routing, no implicit channel changes, unavailable-feed state. Feed publication itself is outside this task and must be reported if outstanding. | +| Signed download and verification | Pinned Tauri updater verifies bytes; package path checks registry SRI | `010`, `030`, `040`, `070`, `090`, `100`: exact signed artifact and complete executable closure, offline positive/negative verification and owner-bound stage for each owner. | +| Download progress and staged readiness | Tauri download currently occurs inside installation | `030`, `050`: distinguish progress, verified ready and unknown; retain or re-establish stage with safe recovery. | +| No-interruption automatic activation | Terminal drain can abort turns at its deadline | `020`, `030`, `040`, `060`, `070`, `080`, `090`, `100`: fenced idle admission, owner-bound stop, race and long-stream tests, and safe deferral for each owner and link direction. | +| Restart, Later and user controls | Explicit desktop install and package dialog | `030`, `040`, `050`: persisted independent check/download/install controls, defined default, explicit install-on-quit option, Later and restart behavior across quit/restart. | +| Sidebar state and release notes | Existing availability dot and update page | `050`: current, available, downloading percentage, ready and failed state; safe localized panel, release notes and keyboard/visual proof. | +| Sidebar version readability | Footer version currently ellipsizes | `050`: stable and long version geometry and accessible full value at relevant viewport/zoom sizes. | +| Failure, rollback and health | npm transaction rollback and desktop failed-install recovery | `030`, `040`, `060`, `070`, `090`, `100`: signed recovery material, durable journal, target health, probation, failed-target suppression, recovery and uncertainty tests for each owner. | +| Linked-machine update and sync | Home fan-out sync, peer features and compatibility | `060`, `080`: separate authenticated update contract for both link directions, independent Child updates, destination policy and Desktop owner bridge, target compatibility, durable fleet resumption, per-Child results and post-update sync; sync alone does not close this gap. | +| Standalone installation updates | Standalone release artifacts exist; current orchestrated plan refuses unsupported ownership | `070`: signed platform artifact, owner-bound replacement, guarded stop, rollback and health proof for every supported target. | +| Telemetry-free update behavior | Existing update checks and local logging | All units: no new update telemetry, bounded local status and passing privacy scan. GOAT's broader telemetry is documented in `001`; do not misstate it. | +| Mock and isolated testing | Existing isolated update fixtures and GOAT mock reference | `010` and each implementation unit: test keys, local mock feed, isolated homes and no real service or installation changes. | +| Security, docs and delivery | Existing ownership, release and test gates | Each unit: independent security PASS where required, docs/locale/structure sync, Lab boundary, file-size/test-layout guards, exact-head CI and a `dev` PR reported to the coordinator without merging. | + +Open design blockers: signed trust-root provisioning and rotation, exact +manager-specific signed staging and rollback, complete activity fencing and +stop-to-installer handoff, remote operation authorization and future-version +compatibility metadata, nightly publication routing, cross-platform recovery, +and exact field-chain/test mapping. Their resolution and independent audit are +required before the roadmap can be locked. diff --git a/devlog/_plan/261107_auto_update_parity/003_contracts.md b/devlog/_plan/261107_auto_update_parity/003_contracts.md new file mode 100644 index 00000000000..6e290a10b28 --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/003_contracts.md @@ -0,0 +1,161 @@ +# Proposed update contracts — draft + +These names bind the roadmap drafts to one vocabulary; none is implemented or +approved yet. Security review must resolve exact encoding, authorization and +rotation before a unit is executable. Existing `ocx.update.plan/1`, +`ocx.update.result/1`, `ocx.sync.fanout/1` and desktop v1 snapshots remain +compatible contracts; do not add fields to their strict parsers in place. + +| Contract and fields | Creation → serialization → parsing → consumers | +| --- | --- | +| `ocx.update.root/1`: `schema`, `revision`, `expiresAt`, `rootKeys`, `rootThreshold`, `releaseKeys`, `releaseThreshold`, `channels` | A planned trust-root record would define public verification material and rotation; the design remains subject to independent security review. | +| `ocx.update.release/1`: `version`, `channel`, `sequence`, `sourceCommit`, `rolloutNotBefore`, `targets` | The proposed release builder would create and sign metadata (`010`); the planned `signed-supply.mjs` would verify the signature before strict parsing, and policy, package and remote selection would consume the verified candidate (`040`, `060`). Native Desktop would select its target and delegated artifact key from that record and separately verify the artifact through Tauri (`030`). Unknown schemas or signatures would be refused. | +| `ocx.update.tree/1`: `version`, `target`, `entries` with path/type/mode/size/digest and internal-link target | After package preparation (`010`), the proposed signed release record would bind inventory bytes and archive digest. A strict extractor and planned `signed-tree.mjs` would validate the complete set before execution; the package adapter and recovery would consume the verified stage (`040`). No transport should introduce extra tree entries. | +| `ocx.update.channel-freshness/1`: `channel`, `freshnessSeq`, `releaseSeq`, `releaseDigest`, `issuedAt`, `expiresAt`, `rootRevision` | Renewable signed channel freshness is proposed; a compatible publisher and discovery path have not been demonstrated. Installation owners would validate expiry, selected release and per-channel high-water state as specified in `010`, subject to its open publication and validity blockers. | +| `ocx.link-capabilities/1`: exact version, channel, target and artifact digest; provided and required protocols; update actions; installer, recovery and journal-reader versions | A planned release descriptor would bind compatibility information to its candidate for fleet comparisons. | +| `ocx.update.policy/1`: `revision`, `channel`, `check`, `download`, `install` | Proposed native and package policy owners would persist and validate policy records, with corrupt or unreadable state disabling automatic effects. | +| `ocx.update.prepared/1`: `operationId`, candidate identity, stage identity/digest, installation owner, policy revision | Installer owners would create a verified, owner-bound prepared state and expose only status to the UI. | +| `ocx.update.activation/1`: operation ID/generation, runtime instance, endpoint, installation owner, candidate/stage digest, policy revision, deadline | A planned local coordinator would bind admission to the exact operation and owner before activation. | +| `ocx.update.stop/1`: operation identity, disposition, runtime-exit and manager observations, teardown state | A proposed stop owner would provide an observed, operation-bound result before a verified swap. | +| `ocx.desktop.update/2`: current version, candidate identity/channel, phase, bounded progress/error, policy revision and values, release-note text, deferred state | The planned native panel status would represent the candidate, stage readiness, check errors, policy state and safe release notes independently. | +| Desktop snapshot v2: `schema`, `sessionId`, `sequence`, `currentVersion`, `latestVersion`, `available`, `checkedAtMs`, `phase`, `progressPercent`, `errorCode`, `installedChannel`, `policyChannel`, `candidateChannel` | A proposed versioned publisher and decoder would provide a bounded display-only snapshot with explicit unknown values and legacy negotiation. | +| `ocx.desktop.badge/2`: `schema`, `sessionId`, `currentVersion`, `latestVersion`, `updateAvailable`, `unknown`, `phase`, `progressPercent`, `errorCode`, `installedChannel`, `policyChannel`, `candidateChannel` | A planned versioned desktop badge would project nullable channel and availability observations, while the old badge retains its existing compatibility behavior. | +| `ocx.link-update.envelope/1`, `ocx.link-update.request/1`, `ocx.link-update.result/1`: operation identity, link, candidate, destination policy revision, expiry and action | A proposed destination-authorized update exchange would carry bounded operations and results independently of sync. | +| `ocx.link-update.grant/1`: grant identity/epoch, Home public key identity, Child/configuration/installation and link binding, allowed actions and expiry | A planned destination-local grant would restrict authorized update operations and be revocable. | +| `ocx.link-update.dashboard-status/1`, `ocx.link-update.dashboard-action/1`, `ocx.link-update.dashboard-action-readback/1` | Proposed management endpoints would expose bounded status and accept revision-bound actions without transferring destination authority. | + +The new field names above must be reflected verbatim in each unit's final +file map and tests. The proposed per-platform signed tree is a publisher +attestation of final build output; it is not a claim of reproducible builds. +Avoid sensitive data in all status records and logs. Capability absence or an +unknown schema produces an explicit unavailable/unknown result, never a +fallback to an unverified install. + +## Exact release-candidate binding + +`ocx.update.release/1` is a closed object containing `schema`, `version`, +`channel`, positive safe-integer `sequence`, full source commit, +`rolloutNotBefore`, `receipt`, `releaseNotes` and a bounded nonempty `targets` +array. `receipt` binds a SHA-256 and size. Each unique target has an exact +platform/architecture/owner-compatible `id`, `artifactKind`, `artifact` +(`sha256`, `size`, `signatureBox`, `signerFingerprint`), `tree` (null for +native bundle formats or signed inventory digest/size), `capabilities` +(descriptor digest/size), and approved source/asset identity. Reject duplicate +target IDs, malformed hashes, missing fields and unknown enums. A consumer +never accepts an arbitrary filesystem path or URL from a caller as install +authority. `010` derives the approved release URL and `030` enforces its +production transport policy. + +The release builder first verifies the exact source, four version files and +artifact set and emits a bounded `ocx.update.verification/1` receipt containing +source commit, version, channel, workflow run/attempt, version-source digests +and ordered asset digests/sizes. The receipt does not contain the later release +record or its own digest. After verification, the signed release record binds +the exact receipt bytes and artifacts, avoiding a circular digest. Target +selection returns a candidate ID derived from a domain separator, the exact +release-record SHA-256 and the length-prefixed target ID. It is a lookup value, +not a replacement for an owner-held prepared handle or consent. + +`010` owns the shared strict parser and golden vectors; `030`, `040`, `060` +and `070` create their owner-specific prepared state only from the verified +candidate. Tests must reject changed receipt/target/signature/bytes, wrong +channel or owner, ambiguous targets, same-sequence substitutions and an +unverified release listing. The selected candidate, policy revision and actual +owner are rechecked before every side effect. Old strict output schemas remain +separately decoded. + +## Publisher payload and manager output + +The publisher-signed tree inventory is an exact closed set for publisher +payload: every listed file/link/type/mode/digest must match and no unlisted +publisher payload is accepted. Package-manager output is a separate local +owner record, never relabeled publisher-signed. Each supported manager/version +adapter has a closed allowlist of generated metadata and bin/link paths, parsed +schemas and canonical wrapper templates; bind every target and path to the +selected installation, global/bin/cache/store identity and verified payload. +Reject unknown generated entries, executable text that differs from an +adapter-owned exact template, links escaping allowed roots, unexpected paths, +unrecognized manager versions/layouts and missing or conflicting ownership. +Do not use a locally generated checksum as publisher authentication. + +The adapter journals the exact validated generated-output digest and owner +identity for readback and recovery; that digest detects drift but does not by +itself authenticate the manager. Manager execution is permitted only through +the selected trusted installed manager and under installation-scoped exclusion; +verify the signed consumed inputs and installed payload again before any new +candidate executable runs. A nonparticipating or unknown writer remains an +explicit unresolved boundary under `020`, not an assumed lock guarantee. +Per-manager tests cover every allowed metadata/wrapper class, foreign entries, +path changes, unsupported layouts and interrupted recovery. The actual supported +version/platform allowlist is enabled only after those tests and independent +security review pass. + +## Signed-root draft encoding + +The root is a strict JSON object with `schema: "ocx.update.root/1"`, positive +integer `revision`, RFC3339 `expiresAt`, `rootKeys` and `releaseKeys` as bounded +arrays of `{keyId, algorithm, publicKey}`, integer thresholds, and `channels` +as a distinct subset of `latest`, `preview`, `nightly`. `algorithm` is the +explicit Tauri-compatible minisign prehashed format; `keyId` is the full +public-key fingerprint, with the format's short identifier checked as well. +Each release-key entry additionally names its nonempty `channels` and `roles` +subsets; a target may select it only for an explicitly delegated channel and +role. Root-key entries have no release delegation fields. Allowed release +roles are `release`, `tree`, `desktop-artifact`, `standalone-artifact` and +`guardian-artifact`; threshold and unique-key checks apply separately to the +role being verified. The proposed desktop-artifact role is constrained to a +single signer until a multi-signature bridge is designed and reviewed. +The representation of independently configurable role thresholds remains +unresolved: the table's singular `releaseThreshold` is a placeholder, not a +final schema. Resolve and independently review that encoding, including +desktop-artifact constraints, before any implementation relies on it. +Detached signatures carry `{keyFingerprint, signatureBox}` in the envelope +defined in `010` and authenticate the exact file bytes. Reject duplicate keys, duplicate JSON names, thresholds outside the +unique key count, unknown fields and unsupported algorithms. Initial threshold +is one pinned root key and one delegated release key; a feed cannot add a new +root without the rotation rule above. The signature-envelope proposal and +required cross-runtime test vectors are in `010`; they require independent audit. + +The native candidate bridge is a planned integration. Its security design and verification remain subject to independent review. + +Channel discovery freshness, expiry, renewal and rotation are proposed in +`010`. Publication through an existing authorized path and independent +security review remain prerequisites; none of these contracts is implemented. + +## Future link compatibility descriptor + +`ocx.link-capabilities/1` is a closed JSON object with `schema`, `version`, +`channel`, `target`, `artifactSha256`, `homeProvides`, `childRequires`, +`updateActions`, `installerAdapters`, `recoveryProtocols` and +`fleetJournalReaders`. Each of the last six fields is a bounded array of +unique `{id, version}` pairs: `id` is a stable ASCII protocol/adapter/action +identifier and `version` is a positive integer. Arrays express exact supported +versions, not ranges inferred from release strings. Empty arrays are explicit; +missing, malformed, duplicated or unknown schema data is `unknown`. The +signed target record binds the digest and length of the exact descriptor bytes; +the descriptor's artifact digest and target must equal the selected target. +This avoids a circular release-record digest. A descriptor for a retained old +artifact must itself be publisher-authenticated and bound to that exact +artifact; a current peer report is not a substitute. + +Add `compatibilityEpoch` and `compatibilityVectorSha256` to the descriptor. +The vector is canonical encoding of the provided/required protocols, update +actions, recovery and journal-reader versions relevant to interoperation, with +strict ordering and duplicate rejection. Publisher release checks certify that +every artifact declaring the same epoch and vector implements that vector; a +changed vector requires a new epoch. The artifact-specific descriptor still +binds version, target and digest, while the shared vector identifies the +compatibility horizon rather than claiming different artifacts have the same +digest. The release verifier must test this invariant against the source-owned +feature and protocol implementations before signing. + +The `060` comparator first verifies both descriptors and their exact artifact +bindings. For each required `{id, version}` in the Child's `childRequires`, the +Home's `homeProvides` must contain the identical pair. The selected update +action, destination installer adapter, recovery protocol, and journal-reader +version required by the specific operation must likewise be explicitly present +in the relevant descriptor. A valid descriptor with a missing required pair is +`incompatible`; absent or unverifiable evidence is `unknown`. Neither result +permits automatic activation. Comparator test vectors cover all Home/Child and +recovery pairs, same release with different artifact, explicit empty arrays, +unknown schema, duplicate entries and forged or missing descriptor binding. diff --git a/devlog/_plan/261107_auto_update_parity/010_signed_supply.md b/devlog/_plan/261107_auto_update_parity/010_signed_supply.md new file mode 100644 index 00000000000..32f51412cf0 --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/010_signed_supply.md @@ -0,0 +1,386 @@ +# Signed supply and channel contracts — draft unit + +**Status:** evidence and design options only. The exact artifact and trust +contract requires the architect proposal, independent security review and +roadmap audit before this becomes an executable unit. + +## Existing supply + +The release verifier expects standalone archives with SHA-256 sidecars, while +its required updater signatures cover desktop updater targets +(`desktop/scripts/verify-release-assets.ts:45`). The Tauri feed names exact +desktop artifact URLs and signatures (`desktop/scripts/updater-manifest.ts:18`). +The verifier pins the configured updater public key and verifies Ed25519 +signatures (`desktop/scripts/verify-release-assets.ts:180`). npm trusted +publishing generates provenance (`.github/workflows/release.yml:1151`); a +publisher-verification contract must be established for the requested update +experience. Integrity metadata alone is not a publisher signature. + +## Required design decision + +The read-only `auto-update-signed-supply-research-01` investigation recommends +publisher-signed, self-contained installation trees as the primary candidate. +Existing npm provenance remains an alternative, but its entire executable +dependency closure and offline verification are not yet established. The +proposal is not a security review or proof that artifacts already exist. +Prove a reproducible, offline-testable signature contract for the *exact bytes* +used by every automatic installation, including publisher, version, platform, +channel, key rotation and artifact-to-install binding. A release must never be +published as part of this task. + +Proposed signed supply consists of a versioned trust root +(`ocx.update.root/1`), a release record (`ocx.update.release/1`), a complete +tree inventory (`ocx.update.tree/1`) and a directly signed tree archive for +each supported platform. Sign exact UTF-8 metadata bytes, not a reconstructed +serialization. The release record binds source commit, version, channel, +sequence, target, archive digest and size, and tree-inventory digest. The tree +inventory covers every executable and dependency, including Bun, native keyring, +optional native modules and generated compatibility assets. Reject duplicate or +unknown fields, unsupported algorithms, invalid paths and oversized input. +The release builder freezes the tree only after package preparation +(`package.json:71`). + +Proposed initial trust root: the existing updater public key already pinned in +`desktop/src-tauri/tauri.conf.json:68`, using the Tauri-compatible +Ed25519/BLAKE2b signature format and verifier precedent +(`desktop/scripts/verify-release-assets.ts:186`). The release record and tree +archive must each verify under a release key delegated by that pinned root; +a feed-provided key alone is never a trust root. Root revision updates require +authorization from both the currently trusted and replacement roots; persist +the highest accepted revision and channel sequence. A lost or compromised root +requires a separately authorized manual software/key recovery; do not silently +trust a replacement from the feed. Test fixtures use separate keys only. + +The research lane also proposed a two-of-three offline root. The initial plan +does not assume three existing maintainer keys or perform a key ceremony; it +starts from the repository's existing publisher trust anchor. Security audit +must assess that operational tradeoff, the exact signer availability and +rotation procedure before implementation. No private signing material is +created, requested or used during this task's local tests. + +## Proposed detached signature format and rotation + +The existing release verifier parses the Tauri public-key box as `Ed`, an +eight-byte key ID and 32 public-key bytes, and its updater signature as `ED`, +an eight-byte key ID and a 64-byte signature +(`desktop/scripts/verify-release-assets.ts:155`, `:186`). `ED` verifies ordinary +Ed25519 over the BLAKE2b-512 digest of the **exact input bytes**, followed by +the minisign global signature over the raw signature and trusted comment +(`:204`, `:212`). It is not Ed25519ph. Keep Tauri's base64-wrapped minisign +signature box for artifacts; do not invent a different cryptographic meaning +for an existing `.sig` file. + +For root, release and tree metadata, propose a separate closed +`ocx.update.signatures/1` JSON envelope `{schema, signatures}`. Each entry is +`{keyFingerprint, signatureBox}`, where `keyFingerprint` is lowercase SHA-256 +of the 32-byte Ed25519 public key and `signatureBox` is canonical standard +base64 of the four-line Tauri minisign box. Each signature authenticates the +exact accompanying metadata file bytes; no parse-and-reserialize step occurs +before verification. The expected metadata schema and verification role are +chosen by the consumer and checked after cryptographic verification; a root +record cannot be accepted as a release record. Limit root metadata to 64 KiB, +release metadata to 1 MiB, tree inventory to 16 MiB, signature envelope to +64 KiB, and keys to 16 per root or release role. A root-rotation envelope may +carry up to 32 signatures so disjoint old and new root thresholds can both be +satisfied; other role envelopes remain limited to 16. Reject duplicate fingerprints, +noncanonical encodings, unknown algorithms, short packet IDs that disagree with +the full authorized key, malformed UTF-8, invalid global comments, duplicate +JSON keys and schema/role mismatches. Initial key threshold is one, as stated +above; threshold counting is over distinct authorized full fingerprints. + +The proposed embedded initial root delegates release keys per channel and role; the +verified release record selects exactly one authorized artifact key fingerprint +for each target. Until a multi-signature artifact bridge is designed and +independently reviewed, the desktop-artifact threshold is exactly one; other +thresholds retain their own role rules. The native client would verify the root and release first, select +the target's delegated key, then passes the corresponding verified minisign +public-key box to the updater builder's `pubkey` override. The locally installed +`tauri-plugin-updater` 2.14.1 exposes this override in `src/updater.rs:348`; +the signed target digest and signature still bind the exact downloaded bytes. +The existing release verifier currently checks the configured pinned updater +key (`desktop/scripts/verify-release-assets.ts:202`), so the supply unit must +update it to check only an authorized delegated key and exercise both old and +rotated configurations. Root rotation accepts a strictly higher root revision +only when signatures by distinct keys meet both the current-root and proposed- +root thresholds; write the validated root and high-water mark atomically before +using newly delegated keys. Replayed or partially authorized root transitions, +unknown channels, mismatched key IDs and an interrupted persistence step refuse. +Include a disjoint old/new root-key vector at the maximum thresholds, plus +desktop artifact role and rotation vectors. + +Desktop artifact verification and the release-verification path require independent security review and validation before implementation. + +## Platform and owner matrix to implement + +| Surface | Existing published build targets in source | Required automatic behavior | +| --- | --- | --- | +| Tauri desktop | macOS arm64/x64, Windows x64, Linux x64 AppImage/deb (`desktop/scripts/updater-manifest.ts:30`) | Use the signed desktop feed for the exact bundle target; a missing target/channel is unavailable. Verify actual release assets and the Linux bundle-type selection. | +| Standalone runtime | macOS arm64/x64, Windows x64, Linux x64/arm64 (`scripts/standalone-targets.ts:7`) | A signed exact-platform artifact and an owner-specific replacement/recovery contract are required; current standalone ownership is not automatically eligible. | +| npm global | npm-owned package roots and bin links; platform-specific dependency tree selected by installation | Provide a signed complete tree for each supported platform and prove ownership, bin compatibility, stage and rollback. Missing closure is unsupported. | +| pnpm and Bun global | Existing owner-specific global operations, not a proved download-only stage | Implement and independently verify each manager's signed offline staging, activation, shims and rollback. Until proven, show unsupported; these rows remain open parity work. | +| mise, source, unknown or foreign owner | Existing plan refuses them (`src/update/orchestrated-plan.ts:28`) | Keep the refusal and provide the owning tool's manual guidance. A desktop-owned bundle uses the native desktop owner, never a package-manager swap. | + +The release record's `targets` entries must identify platform/architecture, +owner-compatible artifact kind, signed archive size/digest, signed tree digest +and supported link capability metadata. They are a closed list of what the +release actually produced, not inferred from the host version. Tests must prove +each advertised target has a matching signed artifact and that an unlisted +target cannot be selected. + +Each signed release record also binds bounded, plain-text release-note items +to its exact version and channel. The panel may gather verified same-channel +records newer than the installed version through the selected candidate and +format them under the bounded grouping contract in `050`; an untrusted +release listing locates records but supplies no authenticated notes or count. +Missing history remains explicitly unknown and never changes artifact trust. + +Client preparation verifies signed metadata and archive bytes, extracts as +data without lifecycle scripts, validates every inventory entry and rejects +unlisted entries, then binds the owned stage and installation identity to an +opaque prepared handle. At activation, reacquire the existing service ownership +and sync/update guards, obtain the installation-wide coordination required by +`020` once designed and proven, revalidate the handle and stage, and execute only the +verified absolute paths after the guarded stop. The client must not fetch a +replacement dependency or run a package lifecycle script after verifying a +different archive. Publisher-signed output authenticates the publisher's +artifact; it is not a claim that the build is reproducible from source. + +The user requires signatures for automatic updates, stronger than GOAT's +documented HTTPS-plus-checksum runtime supply +(`docs/internals/remote-updates.md:163`, GOAT reference revision). A missing, +invalid, unknown-publisher or mismatched signature yields a visible refusal, +never a fallback to unsigned bytes on the migrated path. Existing manual +commands retain their current behavior until each manager's verified adapter +and recovery contract are ready. Each later migration must preserve documented +command interfaces while clearly reporting that missing verified supply can +cause a previously successful installation to be refused; it must not claim an +unimplemented manager has already migrated. + +Channel parity is an open deliverable, not a UI-label substitution: current +release authority accepts `main`/`latest` and `preview`/`preview` +(`scripts/release.ts:499`, `.github/workflows/release.yml:1051`), while GOAT +publishes a distinct nightly feed. The final design must specify how a signed +nightly feed would be produced under the existing workflow trigger and +permission constraints, how preview and stable feeds are selected and +authenticated, and how a client behaves before a feed has actually been +published. This task may implement and test the release machinery but may not +publish a release. An unavailable feed remains an explicit outstanding item, +not proof of parity. + +Separate signed stable, preview and nightly manifests are a desired outcome, +not a demonstrated publication route. The earlier idea of +stamping a version into a workflow checkout after CI is rejected: current +release publication requires successful push CI for the exact source commit +(`.github/workflows/release.yml:1077`) and all version sources to match that +commit (`scripts/ci/release-preflight.sh:59`). A transformed source tree has +not received that proof and this task cannot weaken the CI policy. + +The existing release authority permits only `main`/`latest` and +`preview`/`preview`. A separate nightly npm tag or feed has no proven route +under those unchanged gates. Keep nightly unavailable until an independently +reviewed publication design demonstrates compatibility with the current +branch/tag policy, workflow triggers, permissions and CI requirements; do not +change those gates to make this roadmap work. No part of this task creates a +release commit or publishes a release. + +The four sources are `package.json`, `desktop/src-tauri/tauri.conf.json`, +`desktop/src-tauri/Cargo.toml` and the `opencodex-desktop` entry in +`desktop/src-tauri/Cargo.lock` (`scripts/release-version-sources.ts:153`). +Keep the expected-SHA/trusted-dispatch gate and exact commit CI proof. In every +packaging and publishing job, checkout that SHA and verify the four sources +against the selected version before generating assets; no workflow-side version +stamp is allowed. A signed release record binds channel, version, source SHA, +the verification-receipt digest and each target artifact digest. The receipt +also records the release workflow run/attempt and the exact four source-file +digests; verification checks the source bytes against the checkout and receipt +before signing. Release-generated packaging output is separately identified and +verified, not represented as a tested source commit. + +Preserve both early and final branch/tag/version gates, release argument parsing, +cross-channel global-floor checks, prerelease classification and resume logic +(`scripts/release.ts:499`, `scripts/ci/release-preflight.sh:34`, +`.github/workflows/release.yml:1051`, `:1371`). Resume remains bound to the same +SHA and version under existing publication-side identity checks. Any future +compatible channel design must prove that existing tags and exact-SHA CI remain +protected and that changed or absent receipts fail closed. npm trusted-publisher +configuration and the initial signed feed remain operational prerequisites; no +feed is claimed live until separately published by a maintainer. + +If a compatible nightly route can be established, version spelling alone must +not be used to infer its channel. Every supported installed owner stores an +owner-private authenticated provenance record binding installation identity, +exact artifact digest, signed release-record digest and channel. The installer +writes it only after verification and preserves it across startup and recovery; +the new code verifies the release record and actual owner before trusting it. +For a legacy installation without a provenance record, a stable version can +bootstrap as `latest` only with matching authenticated evidence; an ambiguous +prerelease is `channel-unknown` until the owner explicitly selects a channel +and a verified release establishes provenance. Corrupt, missing or conflicting +records never fall back from nightly to preview. A channel switch is an explicit +policy change and invalidates prior candidate authority. + +Update the complete channel field chain: Node launcher `bin/ocx.mjs:129`, +`src/update/index.ts:78`, `:229`, `src/update/refresh-scheduler.ts:58`, +`src/update/notify.ts`, `src/update/job.ts:226`, +`src/update/orchestrated-arguments.mjs`, `src/update/orchestrated-entry.ts`, +`src/update/desktop-badge.ts:87`, management routes in +`src/server/management/config-routes.ts:948`, and the browser owners named in +`050`. New three-channel check/apply requests use separately versioned routes +and schemas; preserve existing two-channel wire shapes. A new nightly owner +refuses legacy check/apply requests that would infer preview, returning an +actionable upgrade-client state. A new server receiving an old Desktop snapshot +without authenticated channel projects unknown provenance in the new DTO. +For the legacy endpoint it preserves the existing availability projection and +compatibility channel label as specified in `050`; that label is not provenance +or installation authority. +Do not send a nightly artifact to an old updater that cannot preserve its +channel. Test first install, lost/corrupt policy, old/new clients and rollback, +and prove no inferred cross-channel installation occurs. + +For discovery, the client may inspect the GitHub release listing as an +untrusted index to locate a channel-specific signed record at an exact release +tag. Only a verified `ocx.update.release/1` record selects a target and +version; listing order or a mutable dist-tag is never installation authority. +Each immutable release attaches its own signed channel metadata and matching +desktop feed, rather than requiring an existing published asset to be edited. +Missing, stale, malformed, unavailable or unverified metadata leaves the +channel unknown or unavailable. The first in-scope release infrastructure unit +must prove exact source-version and record-to-artifact binding with fixtures; +this routing is still a proposal until the independent security audit passes. + +The proposed `ocx.update.channel-freshness/1` is a closed UTF-8 JSON object +with `schema`, `channel`, a positive safe-integer `freshnessSeq`, the selected +`releaseSeq` and exact `releaseDigest`, `issuedAt`, `expiresAt`, and +`rootRevision`. The existing proposed detached envelope signs its exact bytes +using a release-role key delegated for that channel by the currently accepted +root. Renewal increments `freshnessSeq` even when the release is unchanged. +The installation's update owner would persist the highest accepted freshness +sequence and its exact digest per channel: a lower sequence or equal sequence +with different bytes is refused; equal sequence and digest is a retry. The +freshness record must match the selected verified release, be within its +validity window and name the accepted, unexpired root; otherwise unattended +activation defers without discarding a separately valid retained stage. + +The proposed publisher would publish new immutable freshness records through a +publication path authorized under the existing workflow triggers and policy; +any mutable locator is merely an untrusted index. Neither the publisher nor the +path is established. No workflow trigger change is authorized. The publication +mechanism must be demonstrated under that constraint before implementation. +After a root rotation, the publisher would issue freshness under the newly +accepted root; a stale-root record cannot authorize an effect. If renewal or +rotation publication is unavailable, the channel remains unavailable rather +than accepting expired or previously authorized metadata. Test quiet release +periods, renewal without a release, expiry, stale sequence, digest substitution, +root/key rotation, lost publication, concurrent checks and restart. This is a +draft contract, pending independent security and operational review. + +**Open publication and validity blockers:** current release preflight refuses +republishing an existing version, and published release assets are immutable. +No proven renewal location or discovery path exists yet for a quiet channel +under the current trigger and permission constraints. The draft must also set +an independently reviewed maximum validity window, ordering and clock-skew +rules, and show how the unchanged release remains verifiable after root/key +rotation. Do not implement or claim freshness parity until a compatible +publisher and fixtures establish those properties without changing workflow +triggers or CI policy. + +Proposed rollout for unattended package and fleet activation: stable waits 48 +hours after a signed rollout-not-before value, then opens deterministically +across local buckets over 72 hours; preview and nightly open over 12 hours only +for an explicitly selected channel. The bucket is computed locally from a +random per-installation identifier and exact target version, is never sent as +telemetry, and is stable across retries. Missing or invalid signed time, +unsupported channel, a downgrade and the recorded failed target are ineligible. +Manual checks can show an ineligible release and explain its wait; explicit +manual installation still requires signature and owner checks. GOAT's own +stable/nightly rollout constants are documented at +`apps/server/src/cloud/autoUpdatePolicy.ts:79` in the reference revision; this +proposal deliberately defines preview as a separate opt-in OpenCodex channel. +The current desktop manifest's `pub_date` is generated before publication +(`desktop/scripts/updater-manifest.ts:69`); it is not authenticated actual +publication time. Persist per-channel the highest verified sequence, its exact +release digest and version, and the latest accepted wall time in private owner +state, with an atomic write before automatic effects. A lower sequence, or the +same sequence with a different digest, is refused; an equal sequence and digest +is a retry of the same candidate. Root revision has its own independent +high-water mark. Record first observation per exact candidate; unattended +eligibility uses the later of signed `rolloutNotBefore` and local first-seen, +plus the channel delay. If the clock is unreadable or earlier than the last +accepted time, retain the state and defer until corrected. Test rollback, +restart, same-sequence substitution and write failure. These local checks do +not establish a trusted wall clock against a deliberate forward jump; document +that limit and do not describe them as a publication timestamp proof. + +Only an independently established first installation may initialize absent +root, release and freshness high-water state. If any required state is missing, +corrupt, unreadable or its initialization is ambiguous after that point, retain +remaining evidence and disable unattended update effects; do not treat the loss +as a first install or reset sequence and digest marks. An explicitly authorized +local recovery may resume only after independently establishing non-regressive +root, release and freshness marks and persisting them atomically under the same +owner lock. If their prior bounds cannot be established, refuse automatic recovery +and require a separately reviewed recovery procedure. Defining and proving that +recovery evidence and its authority is an implementation blocker. Test each lost, +corrupt and unreadable record after initialization, stale or incomplete recovery +evidence, failed persistence, concurrent checks and restart; prove that recovery +never accepts a lower mark or a different digest for the same sequence. + +The planned recovery must also handle an accidental forward clock jump followed +by correction: a deliberate local operator action may reset the accepted-time +and first-observation timing baselines only after fresh validation, while +preserving all sequence and digest high-water marks. Restart the observation +delay from the corrected time; never make a target eligible earlier. Exercise +forward-jump, correction, restart and attempted rollback cases in isolation. +The installation's local update-policy owner would perform this action through +an explicitly authorized local control, serialized under the same durable +state lock as discovery, high-water writes and other timing resets. It must +re-read the latest state under that lock, persist the reset atomically, and +refuse if ownership, prior state or validation cannot be established. Tests +must include concurrent checks and resets, stale controls, interrupted writes, +failed persistence and restart, proving that no sequence or digest mark moves +backward. Inside this authorized, effect-free recovery operation only, validate +the corrected operator-confirmed clock and freshly verified metadata without +using the suspect prior wall-time baseline as a gate. Continue enforcing all +signature, channel, sequence, digest and root high-water checks. Re-read state +under the lock, atomically persist the corrected timing baselines, then resume +normal validation; no update effect is permitted before that commit. Invalid +clock or metadata, state changes, or failed persistence leave the old state and +automatic effects disabled. Test this exact recovery, concurrent checks and +failed resets across restart. + +## Candidate file map to resolve + +| Operation | Path | Purpose and evidence | +| --- | --- | --- | +| MODIFY if needed | `.github/workflows/release.yml`, `desktop/scripts/verify-release-assets.ts`, `desktop/scripts/updater-manifest.ts` | Produce and verify the selected signed supply and channel-specific manifests from exact release inputs; preserve the existing verified-bundle handoff (`release.yml:859`) and no new triggers, broadened permissions or unpinned third-party actions. | +| NEW | `src/update/signed-supply.mjs`, `src/update/signed-tree.mjs`, `src/update/prepared-install.mjs` and declaration siblings | Resolve signed metadata, verify bounded exact bytes and complete tree inventory, and retain an owner-held prepared handle. Final APIs, root provisioning, archive extraction and every caller remain audit blockers. | +| NEW | `src/update/update-contracts.mjs`, `src/update/update-contracts.d.mts`, `tests/update/update-contracts.test.ts`, `tests/fixtures/update-contracts/` | Own the first implementation of closed root, release, tree, policy, prepared, activation and capability-descriptor parsers plus shared test vectors described in `003_contracts.md`. `010` lands these before `020` consumes them; downstream owners create records and enforce their own authority. Register the test in both test-layout maps. The comparator remains owned by `060`. Exact exports and byte encodings still require final audit. | +| NEW | `scripts/build-update-tree.ts`, `desktop/scripts/sign-update-supply.ts` | Produce final self-contained per-platform trees and sign the verified bundle in an existing authorized release job; no additional workflow triggers, permission expansion or unpinned third-party actions. Bind exact SHA, toolchain and lock inputs in the signed release record. | +| NEW | `scripts/generate-update-trust-root.ts`, `src/update/trust-root.json` and a generated native resource under `desktop/ui/` | Derive identical initial public trust-root bytes from the existing Tauri public key, with deterministic output and a `--check` mode. The source key is public; tests compare Node/Bun and native inputs byte-for-byte. Never embed private signing material. Persist subsequent verified root revisions under the respective installation's private update state, not in the replaceable package tree. | +| NEW | `tests/helpers/mock-update-server.ts` and isolated fixture builders | Serve only test-generated signed metadata and artifacts from an isolated real root on loopback, using a test key, never the real install or production key. Mirror GOAT's bounded-root and traversal/symlink tests (`scripts/mock-update-server.ts:37`, `:144`; `scripts/mock-update-server.test.ts:23`, GOAT reference revision) and add controllable truncation, wrong-signature, timeout and progress responses. Verify a new helper is actually imported by the focused tests. | +| MODIFY if needed | `src/update/orchestrated-plan.ts`, `src/update/orchestrated-entry.ts`, `bin/ocx.mjs` | Bind automatic plan/apply to exact verified artifact identity while retaining current runtime ownership and token checks (`orchestrated-plan.ts:21`). Avoid widening existing explicit commands by accident. | +| MODIFY | `tests/ci-workflows/release-desktop-scripts.test.ts`, `tests/ci-workflows/release-pipeline-contract.test.ts`, focused `tests/update/` | Failing-first signature, publisher identity, tampered bytes, missing metadata, target/channel mismatch, fail-closed and release receipt tests. Register new domain test files in both layout and expected-fixture maps. | +| MODIFY | Source owners named by `structure/INDEX.md`; public update and release docs and translated pages | Describe only shipped behavior and supported channels; do not advertise a feed without a proven publisher. | + +The candidate file map is not the final implementation. Enumerate every +creation, serialization, parsing and consumer site for any new contract before +locking the plan. Design an isolated fixture with a dedicated test key or +test identity; never use production signing secrets. Prove that a verified +payload is the payload actually installed, not just a separately verified copy. + +## Acceptance and verification to ground + +- A correctly signed target with the expected identity and metadata succeeds in + an isolated home. Wrong key or publisher, altered bytes, absent signature, + wrong version/platform/channel, stale metadata and failed verification all + stop before installation or runtime mutation. +- Desktop stable and preview/nightly choices correspond only to signed feeds + the release workflow actually publishes. Unsupported choices are explicit; + no downgrade or cross-channel move occurs implicitly. +- Release dry-run fixtures verify exactly the assets that would be attached; + receipt version and commit bind the verified set. Tests prove no workflow + trigger, permission or unpinned-action expansion. +- Run the targeted release-script and update tests, `bun run typecheck`, + `bun run privacy:scan`, `bun run structure:check`, and exact-head CI with an + independent security PASS. Confirm every command's actual inputs and record + fresh results before locking or review readiness; no full root suite. diff --git a/devlog/_plan/261107_auto_update_parity/020_activation_admission.md b/devlog/_plan/261107_auto_update_parity/020_activation_admission.md new file mode 100644 index 00000000000..43f40fcd4e3 --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/020_activation_admission.md @@ -0,0 +1,27 @@ +# Automatic activation admission — draft unit + +**Status:** open. No automatic activation or admission guarantee is implemented +by this plan. Architecture, platform behavior and independent security review +must be completed before implementation. + +## Planned outcome + +Automatic activation would proceed only when the responsible runtime and +installation owner can establish that work is idle and the selected update is +still eligible. Busy, unavailable or uncertain state must defer activation. +An operator's ordinary shutdown behavior remains a separate contract. + +## Evidence required before delivery + +- Define and independently review the ownership and coordination contract for + each supported runtime, installation owner and linked-machine direction. +- Demonstrate safe deferral, cancellation, restart and recovery with isolated + test homes and controlled concurrent work; include process interruption and + every supported platform. +- Preserve the core/Compatibility Lab boundary and update the relevant + `structure/` owners only when the behavior actually ships. +- Run focused failing-first regression tests, test-layout and file-size guards, + typecheck, privacy and structure checks, and exact-head CI before delivery. + +Security working analysis and implementation details are kept outside the +tracked roadmap until the corresponding work is public. diff --git a/devlog/_plan/261107_auto_update_parity/030_desktop_engine.md b/devlog/_plan/261107_auto_update_parity/030_desktop_engine.md new file mode 100644 index 00000000000..519c1ae0846 --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/030_desktop_engine.md @@ -0,0 +1,357 @@ +# Staged desktop update engine — draft unit + +**Status:** pending the signed-supply and activation-admission units, architect +reflection and roadmap audit. This is a design draft, not an implementation +authorization. + +## Objective and policy proposal + +The planned desktop app would check its selected published channel, download a +signed update in the background, expose progress, and retain a verified stage +until the person chooses to restart or an enabled policy permits installation. +The design must retain the existing download-verification and ownership-aware +drain boundaries (`desktop/src-tauri/src/updater.rs:388`, +`desktop/src-tauri/src/exit.rs:546`). + +Proposed defaults: background checks and signed auto-download on; automatic +restart/install off until the user enables it. GOAT defaults to restart when +idle (`docs/user/updating.md:128` at the reference revision), but OpenCodex's +existing drain must not be assumed to prove no request is in flight. This +conservative default avoids a surprise restart while still staging updates; +explicit opt-in may activate only after a tested idle/admission signal and a +fresh guarded drain. If a reliable idle signal does not exist, the enabled +policy must defer with an explanatory state, never silently approximate idle. +The user can turn off automatic download, turn off automatic installation, and +choose Later; manual actions remain possible. Finalize defaults and interaction +with quit after architect reflection and audit. + +The pinned Tauri updater exposes `Updater::restore_update` for cached metadata; +it requires signed-version binding for restored metadata, and `Update::verify` +and `Update::install` verify cached bytes again. This was inspected in the +locally installed `tauri-plugin-updater` 2.14.1 crate (`src/updater.rs:666`, +`:896`, `:911`); the repository pins it at +`desktop/src-tauri/Cargo.toml:32` and locks the registry checksum at +`desktop/src-tauri/Cargo.lock:4285`. Therefore durable staging is a concrete +candidate: persist bounded raw metadata plus private staged bytes, restore with +the current configured updater, verify before showing ready, and verify again +at install. Reject signatures lacking the signed version on restoration; do not +serialize a caller-supplied `Update` or trust a saved ready flag. Cross-platform +filesystem ownership, bounded sizes, atomic publication and cache recovery +still need an exact design and negative tests. +Before relying on restoration, verify in release fixtures that the actual +signing tool emits the signed version required by `restore_update`, including +Windows re-signing; otherwise keep the update available for a fresh check +instead of representing the cached stage as ready. + +The native staged engine owns acquisition in a new +`desktop/src-tauri/src/update_download.rs`. It creates the plugin `Update` +only from the verified release record's exact version, channel, target, +artifact digest/size, signature, approved source and delegated key. Reconstruct +and compare these bindings before each download, restoration and installation; +never accept caller-supplied `Update` fields as authority. Use the existing +pinned `reqwest` and `sha2` dependencies (`desktop/src-tauri/Cargo.toml:20`, +`:23`): read response chunks, count before extending a bounded buffer, hash as +they arrive and abort immediately if the signed size or an implementation +maximum is exceeded. Require exact final size and digest, then call the pinned +plugin's `Update::verify` on those same bytes before publishing ready. On +macOS/Linux, `Update::install` verifies them again at installation; on Windows +the verified guardian backend below performs the mandatory final verification +before its observed installer handoff. Reject +partial or oversized responses and remove only this operation's private +temporary file on cancellation/failure. A Content-Length header is advisory, +never the bound. + +Production acquisition requires HTTPS and a source derived from the verified +release record's approved source/asset identity. Initially allow only the exact +`github.com/lidge-jun/opencodex/releases/download//` source; permit +at most three HTTPS redirects to exact hosts `github.com`, +`release-assets.githubusercontent.com` or `objects.githubusercontent.com`, +without userinfo or non-default ports. Refuse an unlisted host rather than +silently widening this set. Send no management, link or user credentials to the +artifact service or redirect target. A loopback HTTP mock is accepted only by +injected test transport, never a production setting. Cap signed size at 1 GiB; +use a 10-second connect deadline, 30-second idle-read deadline and 20-minute +total deadline, with retryable bounded error codes. Test each hop and timeout, +missing/misleading lengths, exact-size boundary, abort cleanup and wrong target, +digest, signature or version. This is a proposed owner and acceptance contract; +the release-asset host set and platform implementation require audit. + +Proposed persisted desktop policy v1, stored by the native owner under its +app-config directory as `update-policy-v1.json`, is a closed object with +`schema: 1`, monotonically increasing `revision`, `channel` (`latest`, `preview` +or `nightly`), `check` and +`download` (`automatic` or `manual`), and `install` (`manual`, `on-quit` or +`when-idle`). +Proposed first-install defaults are automatic checks and downloads, manual +install. Apply them only when an independent owner-controlled initialization +record establishes a genuinely new installation and the installed channel has +the verified provenance in `010`. A missing policy after initialization, or an +ambiguous initialization state, is a recovery error and disables automatic +effects; it is never treated as first install. Ambiguous or missing channel +provenance likewise disables automatic effects. Malformed or unreadable records +also disable automatic effects and report an error. Writes are serialized and +atomic; an unsuccessful write never updates effective policy. Every action +captures the revision and rechecks it before download and activation. + +The proposed owner must durably reserve a strictly increasing revision before +publishing each policy, including a repair, under the same serialized mutation +boundary. A separate owner-controlled high-water record must survive a corrupt +policy and restart; interrupted writes may consume a revision but never reuse +one. If that record is missing, unreadable, corrupt or exhausted and a genuinely +new installation cannot be established independently, refuse policy mutation +and automatic effects rather than reset the counter. Recovery cannot assume a +revision from an unparseable policy. Tests must cover interruption at each +durable step, high-water loss, policy deletion after a manual choice, stale +requests and prepared actions after recovery, concurrent writes, and restart. + +The planned native policy owner would also own the guarded local corrupt-policy +repair command in `050`. It would serialize repair with policy writes, verify +the current owner-issued observation, preserve recoverable prior data and use +manual defaults with an explicitly selected channel and a freshly reserved +revision above the durable high-water mark. A stale observation, +unreadable record or failed preservation refuses without changing policy. +Tests must cover repair, competing writes and owner restart before delivery. + +Later records the exact staged candidate as deferred until the person explicitly +installs it or selects a newer candidate; it survives app restart and ordinary +quit. Ordinary quit must not silently install a deferred or manual candidate. +Enabling `when-idle` permits a non-deferred stage only after the admission unit +proves idle and performs the guarded handoff, including an eligible quit. +`on-quit` attempts installation only on an explicit quit. For either policy, +busy or unknown activity defers the update instead of forcing installation; +the ordinary requested quit follows its existing separate semantics. Later +prevents the candidate's automatic installation on quit as well as while idle. +A successful explicit install or a newer candidate clears the previous +deferral; failed targets remain suppressed for automatic retries until a +different candidate or explicit retry. These are proposed contracts for +review, not evidence of implementation. + +Discovery and staging are separate state owners. If a later check fails, +returns no update, or returns the same candidate with no notes, retain a still +verified staged candidate, its ready/install action, Later decision and notes; +surface the check error separately and allow retry. Invalidate the stage when +channel or owner changes, a newly selected candidate, expired or failed +trust/identity verification, or an explicit discard requires it. A policy +revision change invalidates prior activation authority and requires revalidation; +it does not by itself erase a still-valid stage or Later. An invalid stage never +remains installable. Throttle native progress projection +to changed integer percentages or a one-second heartbeat; sidebar polling is a +separate display concern (`050`). Test the check-versus-ready races, stale +completion, deferred stage and retained notes. + +## Proposed durable recovery contract + +Before the first irreversible stop, atomically journal the candidate, prior +version, install owner, policy revision, operation ID and the pre-stop +Stop/Resume intent. States are `prepared`, `activating`, `observing`, +`installed-stopped`, `healthy`, `failed` and `uncertain`; a process restart never turns an +`activating` record into success without target-version and runtime-owner +readback. A failed target is suppressed until an explicit retry or a distinct +candidate. Record only bounded reason codes, no request data or credentials. + +The candidate activation unit must establish a verified recovery path before +enabling unattended installation: retain a separately verified, signed prior +release artifact and the state needed to restore it, or refuse automatic +activation with a visible manual recovery action. If the recorded effective +intent requires a running runtime, a successful installer exit starts +observation; the exact target and owned runtime must answer health within a +bounded 30-second initial window, followed by a ten-minute probation for +runtime failure. If the person explicitly quit or the runtime was intentionally +stopped, verify the installed artifact and record `installed-stopped`; do not +start a runtime just to satisfy health. On the person's next deliberate start, +observe the exact target and start the health/probation window; retain signed +recovery material until it completes. `installed-stopped` is not `healthy`. +A failed trial/probation triggers the verified owner-led +recovery path; it cannot discard state written by another owner. If safe data +restoration cannot be proven, leave the outcome uncertain and require explicit +recovery rather than presenting a rollback as successful. Desktop platform +installer constraints, the actual persistent journal location and state +ownership need platform-specific design and test evidence before plan lock. + +Proposed recovery actor: a verified, destination-owned guardian is started from +an isolated signed recovery payload **before** the native installer handoff. +The guardian and its journal live outside the replaceable app bundle and hold an +operation-specific lease. It observes installer termination and the exact +replacement's startup/health even if the old app exits. If initial health or +probation fails, it invokes only the verified retained recovery artifact through +the appropriate platform owner; it records `uncertain` when termination, owner +intent or state-write ownership cannot be established. A missing authenticated +previous artifact prevents unattended activation; an explicit manual path +shows the owner action rather than claiming automatic rollback. + +On Windows, the guardian must survive the MSI handoff that ends the old app +(`desktop/src-tauri/src/updater.rs:397`), and wait for the installer process +and owned replacement. On macOS and Linux it survives the app relaunch and +observes the same journal. Rollback may select only the exact retained prior +signed artifact named by the operation record; normal channel sequence and +downgrade restrictions remain in force for ordinary selection. Guarded recovery +is a separate bounded exception, not permission to install an arbitrary older +version. The proposed owners are a new +`desktop/src-tauri/src/update_recovery.rs` coordinator and the separately signed +`desktop/update-guardian/` helper described below; packaging and platform +supervision still require proof before automatic activation. + +The pinned updater's Windows handoff is a specific design constraint: in the +locally installed `tauri-plugin-updater` 2.14.1 source, +`src/updater.rs:1013–1050`, it calls `ShellExecuteW` and exits the app without +returning an installer process handle. Merely watching the old app exit cannot +prove that MSI/NSIS finished or that it is safe to start rollback. The +guardian must obtain a trustworthy installer completion observation through a +platform-specific, independently reviewed handoff; if that cannot be proved, +it records `uncertain` and never launches a competing installer. The unit must +choose and test the actual mechanism, including elevation cancellation and a +live installer after guardian death. Do not claim the current plugin exposes a +handle that it does not expose. + +Build the guardian as a separately signed platform artifact and bind its exact +digest in the release record from `010`, rather than assuming a helper inside +the app bundle will survive replacement. The native owner verifies it before +copying it into an account-private update-operation directory outside the +bundle, verifies the copied bytes again, and launches it with a bounded +operation descriptor and no secret command-line arguments. The guardian owns a +durable journal and exclusive operation lease; the replacement app's startup +and any later owner retry reconcile that journal before starting another +installer. A process surviving application exit is not proof of survival +across logout or reboot: define a startup reconciliation path and leave an +interrupted operation `uncertain` until it observes the prior worker/installer +and owner state. No persistent service registration is implied by this plan. + +| Platform/owner | Proposed recovery boundary | Evidence required before enabling | +| --- | --- | --- | +| macOS bundle | A verified guardian outside the `.app` observes the owned bundle replacement and version-bound startup, then applies only the retained verified prior artifact through the approved platform owner. | Signed/notarized helper execution from private storage, app replacement and restoration on the same and different volumes, permission/elevation refusal, guardian loss, Stop/Quit intent and data ownership. | +| Windows MSI/NSIS | A verified guardian outside the installed tree observes a bound installer operation and replacement process; rollback waits for confirmed installer termination. | Installer process/operation identification across elevation and app exit, job/session survival, cancellation, reboots, concurrent installer exclusion and exact-version readback. `ShellExecuteW` success alone is insufficient. | +| Linux AppImage | A verified guardian outside the image observes the owner-bound replacement and can restore the signed prior image. | Same-filesystem replacement/restore, permissions, interrupted write/rename and exact image verification; the pinned updater's internal temporary backup is not a durable probation guarantee. | +| Linux deb | The guardian uses the selected package owner and waits for verified package-manager completion; it never rewrites a system-owned tree directly. | Ownership and privilege checks, cancellation, manager-lock handling, signed previous package restoration and post-install health. If unavailable, report owner action and keep this parity item open. | + +The exit coordinator adds a distinct `quit-for-update` transition before a +normal UserQuit claims the drain. An eligible explicit Quit atomically records +the user's quit intent and claims this transition; an already claimed UserQuit, +newer Stop/Quit, stale stage or busy/unknown activity follows ordinary quit and +skips update activation. A runtime that was already stopped uses a separate +owner-bound absence proof and installation-wide participant fence; it does not +call the live `/api/update/activation` endpoint. An unknown or foreign runtime +still refuses. Tests cover already stopped, absent, running, busy, concurrent +Quit/Stop and quit ownership claimed before update admission. + +Selected implementation direction: add a small independently signed Rust +guardian artifact per supported target under a new `desktop/update-guardian/` +crate, built and verified in the existing release workflow without adding a +trigger or permission. It reads a private versioned operation journal, acquires +the installation-scoped owner lease from `020`, verifies its own approved +operation identity and both signed target/recovery payloads, then records every +handoff transition before performing it. Its stable private operation directory +is under the Desktop owner's app-config directory, outside the replaceable +bundle. Startup reconciliation launches only the recorded verified guardian; +it never starts a second guardian while the original lease or installer status +is live or unknown. No automatic installation is enabled until packaged tests +show this helper survives the relevant application exit and preserves owner +intent on that platform. + +For Windows, the selected direction is a guardian-owned MSI/NSIS launcher using +`ShellExecuteExW` with a requested process handle, after the native plugin's +`Update::verify` and a second guardian-side verification of the exact staged +installer bytes. The helper accepts only the signed target's approved installer +kind and fixed arguments; it must not accept a caller-supplied command. Observe +the returned handle, installer exit and platform installation state before any +health check or rollback. If elevation is cancelled before launch, restore the +prior intent; if launch succeeds but no trustworthy handle or completion state +can be established, retain the operation fence as `uncertain` and never start +another installer. Test elevated/unelevated execution and Windows Installer's +actual completion semantics. Because the current plugin's `Update::install` +does not return the Windows installer handle, this backend replaces that launch +only after preserving its mandatory exact-byte signature/version verification; +it does not disable or weaken verification. Any direct OS binding is pinned, +reviewed and covered by native CI. + +The durable Windows milestones are `guardian-ready`, `app-exit-requested`, +`app-exited`, `installer-started`, `installer-exited`, `installed`, and either +`observing` or `installed-stopped`. The guardian proves its own readiness and +ownership before the app requests a coordinated exit; it waits for the exact +old app process to exit before launching the installer. It records and observes +the installer handle and completion before starting a replacement app, and +never calls the old immediate `complete_restart` while an installer is live. +If old-app exit fails, no installer starts; if UAC is cancelled before launch, +restore the prior intent. Lost replies, guardian death, handle absence or an +unconfirmed installer retain `uncertain` and prevent another installer. A +replacement starts only when the latest recorded intent requests it; a user +quit remains quit. Exercise every milestone with isolated process/installer +fixtures and packaged Windows proof before enabling the capability. + +On macOS and Linux AppImage, retain the pinned plugin's verified `install` path +and have the independently running guardian observe its bound completion, +exact bundle/image identity and health; the guardian restores only the signed +previous artifact when ownership and data-write checks permit it. For Linux +deb, use a separately supervised approved package-manager operation with an +observable process and package-owner readback; a denied privilege prompt or +unknown process outcome remains explicit. Standalone `070` uses the same +signed guardian protocol for its executable owner, with per-platform replace +and restore adapters; it does not infer ownership from a path alone. Each +platform needs failing-first process-loss, reboot/reconciliation, UAC/privilege +and no-concurrent-installer tests before its automatic capability is enabled. + +Bootstrap for an older installation requires a publisher-signed previous +artifact and authenticated metadata that bind the installed owner and exact +version to the retained recovery material. A version string alone is not +proof of installed bytes. If the old release has no verifiable recovery +material, automatic activation stays disabled until an explicit owner-guided +bootstrap establishes it; the signed-supply and release units must create this +path rather than silently treating unsigned historical bytes as rollback proof. +Tests inject failures before and after guardian launch, stop, installer handoff, +replacement startup and probation; they assert the number of concurrent +installers, the selected owner, and the observed signed recovery outcome. + +## Proposed file map + +| Operation | Path | Before → after | +| --- | --- | --- | +| MODIFY | `desktop/src-tauri/src/updater.rs` | Six-hour check and one combined download/install (`:388`, `:448`) → a single owner for check, signed download with progress, staged readiness and guarded install. Preserve generation, claim and tray races (`:28`, `:291`); do not create a parallel installer or introduce a second scheduler. Extract siblings if size or ownership calls for it. | +| NEW | `desktop/src-tauri/src/update_policy.rs` | Typed, validated channel and download/install preferences; define explicit defaults, durable atomic persistence under the desktop app's own config directory, failure behavior and migration from no record. Resolve exact existing persistence convention before finalizing this path. | +| NEW | `desktop/src-tauri/src/update_stage.rs` | Own a staged exact version and the verified package bytes/metadata returned by the pinned updater; retain verification provenance and invalidate on channel or version change. Persist only if the plugin supports safe re-verification across restart; otherwise recover by rechecking and re-downloading rather than asserting an old stage remains verified. | +| MODIFY | `desktop/src-tauri/src/lib.rs`, `desktop/src-tauri/src/tray.rs` | Route UI/tray operations through the same engine and installation claim. New privileged commands remain guarded by the bundled update page; no new capability is granted to the dashboard origin. | +| MODIFY | `desktop/src-tauri/src/exit.rs` only if necessary | Preserve `prepare_restart` ownership, drain and stop confirmation; any idle evidence must be tested and checked again at admission, not inferred from a timer. | +| MODIFY | Desktop Rust tests and `tests/clients/desktop-update-surface.test.ts` | Add tests for state transitions, concurrent check/download/install, failures, settings and restart admission; use injected clock, updater and temporary app homes. Existing test/source registration rules apply to new domain tests. | +| MODIFY | `structure/desktop-shell.md`, `structure/companion.md`, `structure/ops/desktop-supervision.md`, `structure/runtime.md`, `structure/ops/service-and-sidecars.md` and any other affected owner identified in `structure/INDEX.md`; user docs | Review all mapped owners (`structure/INDEX.md:182`); update each whose documented contract changes. Sync relevant translations. | + +## Required scenarios + +1. Startup and subsequent checks use one bounded scheduler. Offline, timeout, + stale or malformed feed shows a retryable failure or unknown, not current; + a delayed older check cannot overwrite a newer result or active install. +2. A trusted test feed returns an exact signed update; progress becomes visible + and readiness is emitted only after the pinned plugin verifies the bytes. + Missing, corrupt, wrong-key, wrong-channel or superseded artifacts never + become installable. Test signing uses a dedicated fixture key and isolated + storage; never use production credentials or a real installation. +3. Auto-download disabled, auto-install disabled and Later each prevent the + corresponding automatic effect. Changing a preference during a pending + action is rechecked at its mutation boundary. Unknown or malformed settings + fail safely and remain visible as an error. +4. Automatic install requires enabled policy, current verified exact stage, + proven idle, the reservation handoff in `020_activation_admission.md`, a + fresh admission recheck and `prepare_restart` success. An + active request, foreign/unknown owner, failed drain, quit race or changed + stage defers/refuses without replacing the package. Manual install still uses + the same guarded drain. +5. A failed installation invokes the existing recovery owner and reports the + failure; a restart returning on the old version is not called success. A + failed target must not enter a tight automatic retry loop. Explicit retry + is possible. Windows handoff and Linux package type stay covered. +6. With `on-quit`, an idle, non-deferred verified stage is offered to the + guarded installer only at explicit quit. Busy or unknown activity skips the + update; Later, manual mode and failed-target suppression prevent it. With + `when-idle`, the same eligible quit path is available. Test quit races and + verify no in-flight request is interrupted by update activation. + +## Candidate verification + +Run failing-first focused Rust tests and the desktop update-page tests against +isolated homes and a local mock update server; verify the real pinned plugin's +signature rejection as well as injected state-machine failures. Run root +`bun run typecheck`, `bun run privacy:scan` and `bun run structure:check`; +collect exact-head PR checks and an independent security review PASS. The +candidate `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml` +and platform-specific test commands must be grounded and cost-checked before +roadmap lock. No full root suite. A PR-branch `lane=all` requires an explicit +maintainer request on that PR; run it once on the exact intended head, never +redispatch after a head moves or rerun to green, and cancel owned superseded +runs. Ordinary full dispatches remain batch checks on `dev` under `AGENTS.md`. diff --git a/devlog/_plan/261107_auto_update_parity/040_package_preparation.md b/devlog/_plan/261107_auto_update_parity/040_package_preparation.md new file mode 100644 index 00000000000..026cfff248f --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/040_package_preparation.md @@ -0,0 +1,166 @@ +# npm package preparation and activation — draft unit + +**Status:** depends on signed supply and automatic admission; independent +architect reflection and roadmap audit are pending. Do not implement yet. + +## Goal and current behavior + +Support user-controlled staged automatic updates for eligible package-owned +installations while preserving each package manager's ownership, the +sync/update mutex, guarded runtime stop and recovery. npm already stages beside +the live tree and then swaps with backup and rollback, but preparation and +activation are currently one transaction +(`src/update/transactional-install.mjs:388`, `:410`, `:475`). pnpm operates on +its selected global owner (`src/update/pnpm-global-install.mjs:472`); safe +independent staging for pnpm and Bun remains to be demonstrated. Existing +`ocx.update.plan/1` and `ocx.update.result/1` consumers retain their contracts. + +Automatic preparation must use independently reviewed verification and preserve manager ownership. Any manager without a proven safe preparation and recovery contract remains unsupported. + +pnpm's global virtual store and Bun's global metadata/shims need distinct +adapters and isolated proof that the exact signed local bundle can be installed +without fetching or executing unsigned dependencies, and that signed previous +bytes and the manager's links can be restored. Do not rename pnpm store entries +or treat `bun add -g` as preparation. They are separate in-scope units `090` +and `100`; this npm unit cannot close them. + +Preparation holds only its own stage lease; it does not hold the sync/update +mutex while waiting for a person or idle. Activation reacquires the existing +home-scoped sync/update mutex and service ownership guard, and obtains the +installation-wide coordination and runtime reservation proposed by `020` only +after those mechanisms are designed and proven. It revalidates the stage and +policy before performing the receipt-backed stop and atomic swap. The +complete lock order and process-exit fencing must be proven against the +selected manager and platform before the unit is audited. + +The existing `OCX_DISABLE_UPDATE_CHECK=1` environment opt-out +(`src/update/refresh-scheduler.ts:35`) overrides automatic package checks and +downloads even if the stored policy requests them. An explicit manual check +remains available; any new update flow, including a manual install from its +panel, requires the signed-supply verification. Existing explicit CLI behavior +is inventoried separately and is not silently reused as an unsigned fallback. Turning the +environment opt-out off does not clear the user's persisted policy or a +candidate deferral; the UI explains the effective state. + +Persist an operation journal with exact candidate, previous version, owner, +stage identity and `prepared` / `activating` / `observing` / `healthy` / +`failed` / `uncertain` state. Require signed, verified recovery material before +unattended activation. Observe the owned target's version and health within a +30-second initial window and retain rollback material for a ten-minute +probation; do not restore stale data over a newer owner's writes. A failed target +is not retried automatically. A manager unable to guarantee these conditions is +explicitly unsupported until its own implementation unit proves them. + +## Candidate change map + +| Operation | Path | Before → proposed after | +| --- | --- | --- | +| MODIFY | `src/update/transactional-install.mjs` and sibling modules | Extract owner-held `prepare` and `commit` around current isolated staging, tree verification, authorization, swap and rollback. A prepared handle binds verified candidate, exact installation, policy revision and stage identity. Preserve legacy command interfaces and process-tree exit fences while requiring verified supply for installation. | +| MODIFY | `src/update/orchestrated-plan.ts`, `src/update/orchestrated-entry.ts`, `src/update/orchestrated-node-driver.mjs`, `src/update/orchestrated-session.mjs` | Add a separately versioned automatic preparation/activation contract around the existing identity-bound plan and mutation lease. Enumerate construction, transport, decoding, admission and every consumer before locking design; reject stale policy/owner/candidate. Do not change exact-key existing JSON silently. | +| REVALIDATE | `src/update/pnpm-global-install.mjs`, Bun-owned update paths | Verify their existing command interfaces and ownership refusals remain unchanged in this unit. Their signed-supply migration and manager-specific automatic implementations belong to `090` and `100`. | +| MODIFY | `src/update/refresh-scheduler.ts`, package update coordinator sibling | Discovery remains read-only with existing opt-out, cache and backoff; the separately persisted policy owns whether an exact signed target may be downloaded or activated. Integrate admission unit `020`, failed-target suppression and serving-target health readback. | +| NEW | `src/update/automatic-policy.ts`, `src/server/management/update-policy-routes.ts` | The package owner stores a strict `ocx.update.policy/1` record under its effective OpenCodex configuration directory, atomically updates it with a revision check, and exposes authenticated policy reads/changes through the management owner. Only an independently established first installation uses documented defaults; missing after initialization, ambiguous, corrupt or unreadable policy disables automatic effects and requires recovery. Never use a dashboard snapshot as authorization. Wire the route through the existing registry with focused authorization tests. | +| MODIFY | `tests/update/update-transactional.test.ts`, `tests/update/update-orchestrated-*.test.ts`, `tests/update/update-pnpm.test.ts`, `tests/update/update-sync-mutex.test.ts`, relevant CLI/GUI tests | Add red-to-green signature, stage integrity, owner, lease, process-exit, retry, policy and race cases using isolated homes and local fake installers. Register new domain test files in both test-layout maps. | +| MODIFY | `structure/ops/service-and-sidecars.md`, `structure/ops/update-supervised-recovery.md`, `structure/ops/desktop-supervision.md`, any other mapped owners; docs-site and matching locales | Describe actual supported managers, controls, recovery and manual guidance. | + +Existing explicit package commands retain their current behavior until a +manager's verified-supply migration ships. The planned migration preserves +their argument and result interfaces, but may refuse an installation that the +current command permits when verified supply is unavailable. Ship each +manager's migration only after its signed adapter, recovery and compatibility +tests pass; `090` and `100` own pnpm and Bun respectively. Document the change +and supported manual alternatives. Until then, do not claim those managers +have verified automatic updates or silently change their existing commands. + +### Package policy contract + +The proposed `src/update/automatic-policy.ts` would own the strict `ocx.update.policy/1` record +at `/update-policy-v1.json`: `schema`, nonnegative safe-integer +`revision`, `channel` (`latest`, `preview`, `nightly`), `check` and `download` +(`automatic`, `manual`), and `install` (`manual`, `when-idle`). Only an +independently established first installation may use the verified installed +channel provenance in `010` and automatic check/download with manual install. +A missing policy after initialization, or ambiguous initialization state, is a +recovery error that disables automatic effects; it must not reset earlier +manual choices. The package owner must preserve a durable revision high-water +mark and refuse recovery if it cannot establish a fresh revision. Test policy +deletion after a manual choice, owner restart and stale requests. Missing or ambiguous +provenance is `channel-unknown`, not a version-derived preview fallback; an owner +can explicitly choose a channel and establish verified provenance. If the current +channel or install owner cannot be determined, report unknown and do not start +automatic effects. This default permits preparation without a surprise restart; +the current explicit user action is still needed to install until changed. +The Desktop owner additionally supports `on-quit` (`030`); package-owned +background services have no Desktop quit event and reject that value rather +than inventing one. The shared parser accepts the union, and each owner enforces +its own supported values before persisting. + +Use `withConfigMutationLockSync` (`src/config/mutation-lock.ts:96`) for a +short compare-and-swap transaction around an atomic, private no-follow file +write. Parse a valid record and compare `expectedRevision` before writing the +next revision; an unreadable, malformed or unknown-schema existing record is +not treated as missing. Lock/write failure or revision overflow changes +nothing and returns a bounded error. Persist candidate deferral and failed +target identity in the operation owner, not in the channel cache; a policy or +owner change invalidates stale prepared authority. Revalidate the revision at +each effect boundary. The `OCX_DISABLE_UPDATE_CHECK=1` process override blocks +automatic checks and downloads without rewriting the stored policy; the status +must distinguish configured from effective behavior. + +Add authenticated `GET /api/update/policy` and `PUT /api/update/policy` to +`src/server/management/route-registry.ts` and its management dispatcher, with +closed JSON objects and the existing management authentication/CSRF boundary. +GET returns `{schema, policy, effective, error}` where `effective` includes +the environment override and nullable unavailable reason. PUT accepts exactly +`{expectedRevision, channel, check, download, install}` and returns the new +status or a conflict without writing. These routes do not authorize an install +and must not accept the desktop snapshot capability. Old update/check/status +and badge schemas stay unchanged. Add domain-registered +`tests/update/update-automatic-policy.test.ts` and +`tests/server/update-policy-routes.test.ts` for defaults, invalid/missing +records, concurrent revisions, failed writes, environment override, old-client +compatibility and authorization; no real user home is used. + +## Required scenarios + +- Valid signed exact candidate stages without stopping or replacing the running + proxy. Missing, wrong-key, altered, changed target, untrusted transitive + executable or unconfirmed installer exit prevents activation and leaves the + live install untouched. +- Policy, owner, serving root, target, staged tree identity and idle admission + are revalidated before swap. A sync/update conflict, altered stage, changed + channel, ownership handoff or stale reservation refuses without mutation. +- Installation failure restores and verifies the prior tree and applies the + existing manager-aware runtime recovery. A success is distinguished from a + target version serving and passing health; uncertain outcomes never trigger a + duplicate install. Failed targets do not loop automatically. +- Test npm independently on supported platforms; retain interface and ownership + regressions for unchanged pnpm and Bun explicit paths. Their signed-supply + migration belongs to `090` and `100` after each adapter is proven. + Unsupported or externally managed + installers display an honest actionable reason. The `090` and `100` units + remain open until their own automatic adapters are proven. + +## Candidate verification + +Run focused transactional, orchestrated, mutex, supervisor and manager tests +with isolated homes, test keys and mock feed; demonstrate red then green for +the new behavioral regressions. Explicitly run subprocess and source-oracle +tests. Run `bun run typecheck`, `bun run privacy:scan`, `bun run structure:check`, +the core Lab boundary test, and exact-head required PR checks. Obtain an +independent security PASS for package installation and signing. Resolve exact +test commands and prove their input coverage before locking the roadmap. No +full root suite; the coordinator receives a PR and evidence, not a merge. + +Baseline verifier discovery, 2026-11-07: `bun test +tests/update/update-orchestrated-plan.test.ts` passed seven tests using +temporary homes and injected child behavior. `bun test +tests/update/update-transactional.test.ts -t 'manifest verifies|happy path +swaps'` passed two tests and filtered out ten; it uses temporary package trees +and an npm stub. These checks exercise existing plan and transactional owners, +not signature verification or the proposed split prepare/commit contract. + +`bun test tests/update/update-refresh.test.ts -t 'package cache refresh'` +passed 16 tests with 17 filtered out (exit 0) against the existing scheduler's +injected clock and lookup. This verifies existing refresh/coalescing/backoff and +opt-out behavior; it does not read a future policy file or prove signed updates. diff --git a/devlog/_plan/261107_auto_update_parity/050_sidebar_panel.md b/devlog/_plan/261107_auto_update_parity/050_sidebar_panel.md new file mode 100644 index 00000000000..e821aeb3f08 --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/050_sidebar_panel.md @@ -0,0 +1,361 @@ +# Sidebar and update panel — draft unit + +**Status:** pending architecture proposal, upstream engine contract and +independent roadmap audit. Do not implement from this draft yet. + +## Outcome and boundaries + +The proposed dashboard footer would expose desktop update state through the +existing download orb and open the planned update panel. For browser/package +installations, the existing dashboard update flow remains available. The native +update page retains sole native update-command authority +(`desktop/src-tauri/src/lib.rs:272` at the inventory baseline, +`desktop/src-tauri/src/window.rs:115`). +The footer's displayed version must fit ordinary stable versions; long versions +remain fully readable without overlapping action buttons, at normal and narrow +widths and in supported zoom modes. + +## Proposed file map and contract changes + +| Operation | Path | Before → after | +| --- | --- | --- | +| MODIFY | `desktop/src-tauri/src/updater.rs` | Six-field snapshot (`:159`) → versioned, bounded projection of the upstream engine's authoritative phase and progress; preserve session identity, heartbeat and stale-state semantics. The precise shape must be agreed with the engine unit. | +| MODIFY | `src/update/desktop-badge.ts` | Exact six-field parser and availability-only projection (`:31`, `:74`) → explicitly versioned decoder supporting old shell snapshots and the new projection; validate all phase/progress combinations and preserve unknown on missing or expired evidence. | +| MODIFY | `src/server/management/sidebar-routes.ts` | Keep authenticated/capability-bound snapshot publication, its body limit and existing read route (`:105`); only change if the agreed projection requires it. Never add an installation action to this endpoint. | +| MODIFY | `desktop/src-tauri/src/proxy.rs`, `src/update/badge.ts`, `gui/src/lib/desktop-shell.ts` | Negotiate snapshot schema from the bound publication response and use a distinct desktop badge v2 DTO; leave package badge and legacy DTO shapes unchanged. | +| MODIFY | `gui/src/components/sidebar-github-row.tsx` | Availability-only accent (`:133`) → localized and accessible current, available, downloading percentage, ready and failed indications. Unknown/checking is distinct from current. Continue using the existing download button. | +| MODIFY | `gui/src/App.tsx`, `gui/src/design/bridge.css` | Preserve desktop page navigation (`App.tsx:591`); fix footer version sizing and wrapping (`bridge.css:428`) without covering star, update or terminal buttons. Keep full version accessible on mobile. | +| MODIFY | `desktop/ui/update.html`, `desktop/src-tauri/src/lib.rs` | Existing check/install page → native panel consuming the engine's typed status and controls: channel, download and install preferences, release notes, restart and Later. New commands, if required, use the existing local update-page guard; no raw HTML insertion for release notes. | +| MODIFY | `gui/src/i18n/{en,ko,ja,zh,zh-TW,fr,de,ru,pt,tr,vi}.ts` and applicable desktop localization owner | Add every new visible label in all supported locales, with real translations; verify the desktop page's existing localization mechanism before selecting its implementation. | +| MODIFY | `tests/update/update-desktop-badge.test.ts`, `tests/server/sidebar-routes.test.ts`, `tests/clients/desktop-update-surface.test.ts`, `gui/tests/sidebar-version-layout.test.ts`, `gui/tests/desktop-shell.test.ts` | Add meaningful behavior cases for old/new snapshots, state projection, page authority, panel controls and version accessibility. Source-read tests must run explicitly because `test:changed` cannot follow file reads. Add a focused GUI behavior test if no current harness covers the footer. | +| MODIFY | Relevant `structure/` owners from `structure/INDEX.md`, `docs-site/src/content/docs/getting-started/installation.md` and corresponding translated pages | Record current behavior only after implementation; update each affected owner and avoid contradictory localized instructions. | + +New repository test files belong in their `tests//` directory and +require layout and expected-shard registration. Keep ratcheted files within their current line caps; move code to +appropriately owned siblings rather than increasing baselines. The complete +file map must be revalidated after the engine unit lands. + +For package-owned browser installations, extend the existing dialog rather +than creating a second installer UI: `gui/src/pages/dashboard-dialogs.tsx`, +`gui/src/pages/use-dashboard-data.ts` and `gui/src/pages/dashboard-shared.ts` +read the versioned package policy/status, expose latest/preview/nightly and +check/download/install controls, and send revision-bound policy changes. Add +the separately versioned check/apply routes and owner-provenance consumers named +in `010`/`040`; the current two-channel routes and old dialog keep their exact +wire contract. An old client against a nightly installation gets an actionable +unsupported state, not an inferred preview update. Add focused GUI and +management tests for three-channel selection, changed revision, unavailable +provenance, old/new clients and explicit manual update. Update all eleven GUI +locale catalogs and the affected docs-site translations. + +## Proposed desktop snapshot v2 + +The native publisher adds `schema: 2`, a monotonically increasing per-session +`sequence`, `phase`, `installedChannel`, `policyChannel`, `candidateChannel` +and a bounded integer `progressPercent` (0–100 or null) to the existing +session/current/latest/availability/check-time fields. Allowed +phases are `idle`, `checking`, `current`, `available`, `downloading`, `ready`, +`installing` and `failed`; failed requires a closed, non-sensitive `errorCode`, +while other phases use null, never raw errors or release notes. Enforce an exact key set and +phase-specific constraints under the existing 1 KiB request cap +(`src/server/management/sidebar-routes.ts:113`; native proxy cap must also be +kept). Sequence ordering is per session: a late lower sequence cannot overwrite +newer state; a heartbeat at the same sequence may extend expiry only for the +same payload. Unknown versions and stale/missing session evidence remain +unknown. Reject malformed v2 without changing a prior good state. + +Each channel field is `latest`, `preview`, `nightly` or null. The native owner +populates `installedChannel` only from verified provenance bound to the current +installation and actual artifact, `policyChannel` from its valid selected +policy, and `candidateChannel` only from the verified candidate. These fields +can differ after an explicit channel switch. `candidateChannel` must be null +when `latestVersion` is null; it may be null for a retained target whose +provenance is unavailable. Null remains unknown and never implies preview from +the version spelling. The session-authenticated publisher sends the values in +the same bounded v2 body; the server parses and projects them only as display +observations, without granting install authority. A new runtime/session clears +the projection and renegotiates. Legacy v1 snapshots remain decodable but +their absent installed and candidate channel evidence projects unknown; their +availability-only behavior remains intact. + +Upgrade the server decoder and projection before publishing v2. It accepts both +v1 and v2; a v1 shell keeps its existing availability-only behavior. Proposed +negotiation: a new shell first publishes a valid v1 snapshot. A new server's +successful response adds a bounded `snapshotSchemas: [1, 2]`; the old server's +existing `{ok:true}` means v1 only. The native publisher upgrades to v2 only +after a successful response from the currently attested runtime; when the +runtime identity changes it resets to v1 and negotiates again. Authentication, +transport and parsing failures do not count as negotiation or current status. +`desktop/src-tauri/src/proxy.rs:218` currently discards the success body and +must return the validated schema selection without weakening its body-bound +publication capability. Add old-shell/new-server, new-shell/old-server, +runtime-replacement, malformed-response and auth-failure tests. Add a separate +desktop projection DTO without changing package badges. + +### Desktop badge response compatibility + +The existing `GET /api/update/badge?surface=desktop&session=` and its +`UpdateBadge` DTO retain their legacy field names and non-null two-channel type +(`src/update/badge.ts:6`). If the current snapshot has verified installed +provenance for `latest` or `preview`, the old endpoint would return the old +shape with that channel. For a v1 snapshot it would preserve the current +availability projection and legacy `defaultUpdateTag(currentVersion)` label; +that compatibility label is not verified provenance or installation authority. +Missing or expired snapshots retain the existing unknown response. A v2 +snapshot whose provenance is absent or is `nightly` would return HTTP 409 with +bounded code `desktop_channel_unavailable`, rather than invent a third legacy +enum. The package endpoint without `surface=desktop` remains unchanged. Test +an old sidebar against a new server and both v1 and v2 shell snapshots. + +Add separately registered `GET /api/update/desktop-badge-v2?session=` +with the same dashboard-session read authorization as the existing GET. Its +closed `ocx.desktop.badge/2` object has exactly `schema`, `sessionId`, +`currentVersion`, `latestVersion`, `updateAvailable`, `unknown`, `phase`, +`progressPercent`, `errorCode`, `installedChannel`, `policyChannel`, and +`candidateChannel`. `schema` is the literal `ocx.desktop.badge/2`; `sessionId` +is the requested validated UUID or null when no session is supplied; +`currentVersion`, `latestVersion`, `phase`, `progressPercent`, `errorCode` and +the three channels are null when no current trusted snapshot supplies them. +`updateAvailable` is false and `unknown` true without a current snapshot. +With one, phase/progress/error and nullable channels obey snapshot v2 rules; +a v1 snapshot can retain available/current display state but supplies null +channels: v1 `error` and `install-failed` map to v2 `failed` with errorCode +`unknown`, while other v1 phases retain their names and progress is null. +No field is installation authority. Reject duplicate or unexpected +query keys and invalid UUIDs with a bounded 400 result; absent session returns +the unknown DTO. No cache may carry a projection between sessions. + +The updated desktop sidebar constructs the v2 URL and strictly parses this +DTO in `gui/src/lib/desktop-shell.ts` and +`gui/src/components/sidebar-github-row.tsx`; a 404 from an old server, malformed +response, or unavailable session produces unknown while leaving the update +control clickable. It must not use the old inferred channel as a fallback. +The GUI must also use `lastAttemptOk` or an equivalent explicit freshness flag: +after a rejected request, malformed JSON or timeout, retained warm-cache data +is labelled stale/unknown and cannot be presented as a current successful +observation. An HTTP 409/404 or session change clears or isolates prior data; +an independently verified staged install remains controlled by its native +owner, not by the badge cache. Test a warm `current` and `ready` observation +followed by rejection, malformed JSON, timeout, 409 and runtime replacement. +Add explicit serialized-response, versioned GUI parser and route tests in +`tests/update/update-desktop-badge.test.ts`, +`tests/server/sidebar-routes.test.ts`, `gui/tests/desktop-shell.test.ts` and a +new `gui/tests/sidebar-desktop-badge-v2.test.tsx`. Cover old/new shells and +servers, missing/expired/v1/v2 snapshots, same version spelling with preview +versus nightly provenance, policy/candidate changes and runtime replacement; +assert the package and representable legacy responses retain their exact DTO. + +The v2 decoder uses the existing UUID, version and checked-time bounds. The +new `sequence` is a nonnegative JSON safe integer, and `progressPercent` is null +or an integer from 0 through 100. `errorCode` is null or one of `network`, +`feed-unavailable`, `verification`, `ownership`, `busy`, `installation`, +`recovery` or `unknown`; the UI localizes codes and never renders raw errors. +All keys are required even when their value is null. `available` is exactly +whether `latestVersion` is non-null; any target requires `checkedAtMs`. + +| Phase | Target and time | Progress | Error | +| --- | --- | --- | --- | +| `idle` | no target; no check time | null | null | +| `checking` | prior target/time may be retained; absent target may retain a prior check time | null | null | +| `current` | no target; check time required | null | null | +| `available` | target and check time required | null | null | +| `downloading` | target and check time required | integer or null while total is unknown | null | +| `ready` | target and check time required | null | null | +| `installing` | target and check time required | integer or null while total is unknown | null | +| `failed` | target and check time may be retained together; with no target, check time may be null | null | required code | + +Reject a phase/field combination outside this table, a nonidentical payload at +the same sequence, and a lower sequence without replacing the last good +snapshot. A new session never inherits a prior session's sequence or state. +The exact size cap applies after serialization, including multibyte text. +Test equal `-preview.*` version strings with distinct authenticated preview and +nightly provenance; policy loss and channel switching; candidate invalidation; +session/runtime replacement; old v1 snapshots; malformed/oversized v2; and +stale ordering. Assert each v2 provenance channel separately and refuse inferred +preview as provenance or installation authority. Test the legacy v1 display-only +compatibility label and availability exception described above. + +The v1 encoder uses only the existing exact six-field shape. `idle`, +`checking`, `current`, `available` and `installing` preserve their old +meanings; `downloading` and `ready` project as `available` with the target +and its successful check timestamp. `failed` projects as `install-failed` +when a target is retained, or `error` when none is known. `current` carries +no target and a successful check timestamp; `idle` carries neither. A failed +check with no known target stays unknown, never current. Validate these cases +against the existing v1 parser (`src/update/desktop-badge.ts:31`) and the +oldest supported shell/server combinations before locking the contract. + +The desktop footer refreshes the session-scoped badge every five seconds and +every second during `downloading` or `installing`; package/browser polling stays +at its current ten-minute cadence. Apply resource deduplication and stop polling +when the component unmounts. Release notes are fetched from the trusted panel +status, not placed in the 1 KiB heartbeat. These schema and timing values are +proposals subject to audit, and must be tested against both shell/server orders. + +## Proposed native panel commands + +All commands require the existing exact local update-page guard. Add +`update_panel_status()` returning `ocx.desktop.update/2` with current version, +candidate version/channel, phase, bounded progress/error code, policy and +revision, sanitized release-note text, and deferred state. Add +`update_panel_policy({expectedRevision, channel, check, download, install})` +with compare-and-swap semantics; a mismatch returns the current status without +changing it. The install control exposes manual, on-quit and when-idle policy +with the consent and Later semantics in `030`. Add `update_prepare({candidateId})`, +`update_defer({candidateId})`, and `update_activate({candidateId})` returning +the same status plus a bounded accepted/refused result. The native owner +validates IDs and rechecks candidate, policy and ownership; the caller cannot +supply a filesystem path or trust decision. Keep existing `update_status`, +`update_check`, `update_install` and `return_to_dashboard` compatibility and +route them to the same claim and verified installer owner. Exact result field +names and legacy compatibility tests must be frozen with the engine unit. + +Proposed `ocx.desktop.update/2` is a closed result with `schema`, +`currentVersion`, `candidate`, `checkedAtMs`, `phase`, `progressPercent`, `errorCode`, +`stageReadiness`, `checkError`, `policyState`, `policyObservation`, `policy`, `releaseNotes`, +`omittedReleaseCount` and `deferred`. `stageReadiness` independently reports +`none`, `ready` or `unknown`; `checkError` is a bounded code or null. A failed +check may coexist with a retained ready stage, so the panel shows the error and +the valid install action together. `policyState` distinguishes valid, corrupt, +unreadable and missing-after-initialization policy; `policy` is null unless +valid. For a safely observed corrupt or missing-after-initialization record, +`policyObservation` is a bounded opaque owner-issued token; +otherwise it is null. It contains no record contents or credentials and expires +on owner restart. `candidate` is null or +`{id, version, channel}` where `id` is the lowercase SHA-256 identity of the +verified candidate; `policy` is `{revision, channel, check, download, install}` +with the values defined by `030`; `releaseNotes` is an array of at most six +`{version, items}` groups, each with at most eight plain-text items of at most +220 Unicode characters, and `omittedReleaseCount` is a nonnegative safe integer +or null when the total cannot be established. The whole encoded result is +bounded to 32 KiB; `deferred` is boolean and may be true only for a candidate. +Phase, progress and error follow the table above; a target-requiring phase must +have a candidate. Construct release links from an allowlisted release origin +and the validated exact version; do not navigate to a feed-supplied arbitrary +URL. A malformed or unavailable status is displayed as unknown. + +For corrupt or missing-after-initialization policy, disable revision-bound preference changes and offer the +proposed `update_panel_repair_policy({expectedObservation, channel})` only from +the guarded local update page after a deliberate operator action. The native +policy owner serializes repair with normal writes, rechecks that the observation +still describes the same record or verified absence, preserves any recoverable prior data privately, +and atomically creates a valid policy with the explicitly chosen channel and +manual check, download and install settings, using a newly reserved revision +above the durable high-water mark described in `030`. If the mark cannot be +established, repair refuses; it never reuses an earlier revision. A changed observation refuses the +repair and returns fresh status. If the record cannot be read or safely +preserved, repair refuses and the panel shows recovery guidance; it never +silently replaces an unreadable record. Successful repair returns a valid policy +and revision before normal changes resume. Owner and panel tests must cover +corrupt, unreadable and lost records, refusal, successful repair, stale observations, +stale policy changes and prepared actions across repair, high-water loss, +concurrent writes and owner restart. + +The signed release owner supplies same-channel notes for releases newer than +the installed version through the selected candidate. It verifies every +release record used to create a group, orders groups newest first, and reports +omitted groups when a complete bounded history is known; a missing or +unverifiable history gives a null omission count rather than inventing one. +`010` binds each record's notes to its exact version/channel. Render every +item as text. Preserve the staged candidate's previously verified notes when a +later check fails, reports no update or returns the same candidate without +notes (`030`). Test multi-version grouping, channel isolation, omissions, +truncation, malformed/unavailable history and retained ready state. GOAT's +reference groups and limits are `apps/desktop/src/updates/releaseNotes.ts:19`, +`:177`, `:208` at the pinned GOAT revision. + +`update_panel_status` accepts no arguments and returns that object. +`update_panel_policy` accepts exactly `{expectedRevision, channel, check, +download, install}`; the revision is a nonnegative safe integer and the +values are closed enums. `update_panel_repair_policy` accepts exactly a valid +owner-issued `{expectedObservation, channel}`. `update_prepare`, `update_defer` and +`update_activate` accept exactly `{candidateId}`. Mutation commands return +`{schema: "ocx.desktop.update-command/1", outcome, reason, status}` where +`outcome` is `accepted` or `refused`, `reason` is a closed non-sensitive code +or null, and `status` is the freshly observed object above. The native owner +serializes and validates all arguments and uses its existing shared install +claim; an accepted request means accepted work, not installed/healthy. +Unparseable arguments or an origin rejected by the guard are command errors +without state mutation. Client timeout remains unknown until status is read; +the page does not resubmit activation automatically. + +Keep the legacy commands' existing five-field `PageUpdateStatus` +(`desktop/src-tauri/src/updater.rs:263`) and legacy failure behavior for older +bundled pages, with one engine/claim behind both versions. The new page must +detect an unavailable v2 command, display an actionable compatibility state, +and never issue a second install after an uncertain old-command result. Test +legacy/new pages with old/new shell APIs and concurrent old/new command calls. + +## Locale handoff + +The dashboard owns its selected locale (`gui/src/App.tsx:152`) and persists it +on the dashboard origin (`gui/src/i18n/provider.tsx:13`). The trusted bundled +update page is a different origin (`gui/src/lib/desktop-shell.ts:36`), so it +cannot rely on that local storage. Proposed navigation passes only the selected +closed-set locale as an encoded query value to the existing `/update.html` +path. The bundled page validates it against the eleven supported locale IDs +(`gui/src/i18n/shared.ts:6`), falls back to `en` for missing/invalid input, and +sets the matching HTML language. Locale is display data and grants no native +authority; `require_update_page` must continue to check the exact bundled +origin and path (`desktop/src-tauri/src/window.rs:115`). + +The existing Back command uses a stricter settings-page guard that rejects all +queries (`desktop/src-tauri/src/lib.rs:301`, `desktop/src-tauri/src/window.rs:134`). +Change its update-page branch to accept either no query or exactly one `locale` +query whose decoded value is one of the eleven supported IDs. Continue to +require the main window, exact app origin/path, no userinfo or fragment, and +reject duplicate or unknown query keys. The CLI settings page keeps its +existing no-query rule. Add Rust URL-predicate tests and desktop-page tests for +`/update.html?locale=ko` returning to the dashboard, invalid/duplicate values, +other origins and CLI-page queries. The current guard is not assumed to accept +the new navigation without this change. + +Produce `desktop/ui/update-locales.json` deterministically from a React-free +typed table `gui/src/i18n/desktop-update-copy.ts` using +`scripts/generate-desktop-update-locales.ts`; include a `--check` mode in the +desktop build to detect drift. Bundle all eleven translations and keep the +source table as the single owner of panel copy. Render only text, never +HTML from release notes or locale input. Add a test that every locale has each +key and that invalid locale inputs cannot change origin or inject markup. +Test the generator, build check and bundled Tauri asset loading. Do not add a +runtime network fetch for translations. + +## Activation scenarios and evidence + +- Old desktop snapshot is accepted by the newer server and preserves its prior + available/unknown meaning; unknown phases and malformed fields are rejected. +- Missing, expired or another session's snapshot shows unknown; a check failure + never displays up-to-date; a retained ready stage stays installable while the + check error is shown. A current result, available version, progress, + staged result and failure each produce distinguishable labels and states. +- A browser dashboard click reaches its existing package dialog; a desktop click + reaches the bundled panel. Requests from a non-update page cannot invoke + privileged native update commands. +- Preferences survive reopening; Later defers activation and does not erase the + verified staged update. Release notes render as safe text with an appropriate + release link. A failed download or install gives a retryable, accurate state. +- At normal and narrow desktop widths, mobile drawer width, 90% and 100% zoom, + the footer text and all controls remain visible and usable; test long version + identifiers, keyboard focus and screen-reader labels. Capture before/after + UI screenshots through the authorized `pr-assets` path, never in the PR branch. + +## Candidate validation + +Baseline discovery has run the two badge test files (17 pass) and, from `gui/`, +the sidebar version and row tests (12 pass); see `001_inventory.md`. During +implementation run failing-first focused tests, the explicit desktop surface +and sidebar-route files, `bun run typecheck`, `bun run privacy:scan`, +`bun run structure:check`, and from `gui/` `bun run lint:i18n`, `bun run lint`, +`bun test tests` and `bun run build`, plus rendered browser/native proof. These +commands are candidate gates; establish their exact change coverage and record +fresh results before the roadmap is locked or the PR is review-ready. No full +root suite. PR CI must be evaluated at the exact head; request the separate +security review if any authority, updater or installation surface changes. + +Baseline on 2026-11-07: `bun test tests/server/sidebar-routes.test.ts +tests/server/link-management-relay.test.ts` passed 31 tests (exit 0) with +isolated test homes and ephemeral listeners. The sidebar file exercises the +existing six-field snapshot's route, session separation, input limits and +authorization. The relay file exercises the existing passive link boundary. +Neither test proves the proposed v2 snapshot or native panel; add those cases +and demonstrate their initial failure before implementation. diff --git a/devlog/_plan/261107_auto_update_parity/060_linked_updates.md b/devlog/_plan/261107_auto_update_parity/060_linked_updates.md new file mode 100644 index 00000000000..3fc1d2973bb --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/060_linked_updates.md @@ -0,0 +1,451 @@ +# Linked-machine updates — draft unit + +**Status:** depends on the signed supply and local update contracts; architecture, +transport authority and independent security audit are unresolved. Do not +implement from this draft yet. + +## Outcome + +The proposed operator experience would show and control eligible Home and Child +updates, including per-machine state, deferral and failure. Automatic work would be permitted only when +the destination's own setting allows it, the exact artifact is signed and +verified, the destination identity and installer ownership are established, +compatibility is known, and the guarded stop/drain and idle admission succeed. +The Home must not report an offline, stale, unverified or unsupported Child as +updated. All tests use isolated homes, fake links and local mock supply. + +## Proposed transport and durable state + +Use the existing host-key-pinned, non-interactive SSH exec transport for +Home-initiated links (`src/link/ssh-argv.ts:1`, `:70`) and select the Child +executable once, as the pinned sync path does (`:244`). Client-initiated links +without an authorized Home-to-Child exec route are explicitly unsupported until +a separately audited transport exists. Keep the management relay read-only +(`src/link/management-routes.ts:1`); peer reports are compatibility +observations, not authorization (`src/link/peer-report.ts:1`). + +Proposed new CLI operation is `ocx link-update` with `prepare`, `status`, +`commit` and `cancel` subcommands and a versioned bounded JSON request/result +schema: `ocx.link-update.envelope/1`, `ocx.link-update.request/1` and +`ocx.link-update.result/1`. Inputs include an operation UUID, exact link id, candidate identity, +destination policy revision, plan identity and expiry. A `status` read is +idempotent; `prepare` and `commit` record their operation ID before work; +replaying the same request returns the recorded state, while a conflicting +request with the same ID is refused. `cancel` cancels only an uncommitted +operation; after a handoff the caller observes it rather than starting another. +Transport request data is passed as bounded structured input, not interpolated +into shell commands. The Home stores an atomic fleet journal before activation +with target identities and per-Child outcomes, then resumes observations after +its own restart. A healthy Home already at the selected version satisfies the +Home-first prerequisite. A stale or unverified Home does not. + +The proposed exact envelope is `{schema, keyId, payload, signature}` with +`schema: "ocx.link-update.envelope/1"`; `payload` is base64url of the exact +UTF-8 request bytes and the signature covers those bytes and a protocol-domain +separator. The closed request has `schema`, `action`, `requestId`, `nonce`, +`issuedAt`, `notAfter`, `fleetId`, `homeId`, `childId`, `configurationId`, +`installationId`, `linkId`, `linkBindingEpoch`, `grantId`, `grantEpoch`, +`operation` and action-specific `args`. `prepare` names the destination policy +revision and exact signed candidate digests; `commit` names the prepared handle, +plan digest, expected operation revision and Home health barrier; `cancel` +names the operation and expected revision; `status` is a read-only discovery or +one-operation query. Each action is separately signed and cannot carry an +arbitrary executable path, command or download URL. Bound the encoded envelope +and result to 64 KiB, decoded request to 32 KiB, input to five seconds, and +request validity to five minutes; these are proposed testable limits. + +The response `ocx.link-update.result/1` binds `requestId`, request digest and +nonce, destination and grant/binding identities, operation identity/revision, +durable phase, bounded reason, candidate and policy revision, and separate +`commitAccepted`, `mutationStarted`, `installationVerified`, `runtimeHealthy` +and `rollbackVerified` observations. It is signed by the enrolled destination +response key under a separate domain. Unknown schema, malformed encoding, +duplicate keys, mismatched request binding or unavailable identity is a refusal +or unknown result, never success. These schemas require independent security +review and an exact implementation-level field chain before the plan is locked. + +The Child independently checks its durable link connection under the lifecycle +and configuration locks used by its existing expectation check +(`src/client/sync-expectation.ts:108`), its own opt-in policy/revision, selected +executable and install owner, capability, exact signed candidate and fenced +idle admission. SSH authentication alone and the unkeyed plan-consistency hash +(`src/update/orchestrated-token.mjs:3`) are not a replacement for destination +consent or operation authorization. The request proof, replay journal and +lock ordering need a concrete protocol and independent security review before +the roadmap is locked; this paragraph does not claim those controls exist. + +The independent `auto-update-linked-authority-research-01` lane recommends a +separate Home update signing key and a destination-local grant bound to the +Home, Child, installation and link generation. Creating or approving that grant +is an explicit action by the destination owner; an agent must not auto-answer +it, and old links get no implicit update authority. Separate local policy +controls preparation and activation. A Home request cannot enable a disabled +destination policy or clear its deferral. Key provisioning, response proof, +rotation and revocation are proposed design work, not existing features or a +security PASS; tests use isolated keys and homes only. + +Proposed owner-facing local commands are `ocx link-update grant list --json`, +`ocx link-update grant approve --request `, and +`ocx link-update grant revoke --grant `. Approval and revocation act only +on the destination's local grant store and require the repository's existing +person-consent boundary; a Home request may present a public-key enrollment +request but cannot approve it. The CLI registry, capabilities and generated +skill surface advertise the new contract only once dispatch and grant +operations exist. A lost approval response is reconciled by grant ID, not +automatically approved again. No agent or test may approve a real user's grant; +tests use isolated fake destinations. + +The proposed grant store is `/link/update-grants.json`, owned by +the destination account and written atomically with the existing private-file +policy. Each strict `ocx.link-update.grant/1` entry contains `grantId`, +`grantEpoch`, `homeId`, `homeUpdatePublicKey`, `childId`, `configurationId`, +`installationId`, `linkId`, `linkBindingEpoch`, `apiKeyId`, `scopes`, +`expiresAt`, `revoked` and `childResponsePublicKey`. The destination response +public key is enrolled with the grant; private key material belongs in the existing secure +credential owner, never in a status projection or source control. Grant +approval, revocation, connection change and local policy changes serialize +with the durable destination binding. The precise key-storage adapter and +person-consent API must be verified against existing platform implementations. + +The proposed key owner is a new `src/link/update-key-store.ts`, independent of +provider API-key accounts. Store the Home update signing key and each Child +response private key under the respective owner's private link directory using +the existing no-follow, owner-only file and required Windows ACL patterns +(`src/codex/installation-id.ts:19`, `:40`); verify a newly written public key +against the private key before publishing a grant. Never print, transmit or +copy private keys into journals, status or config. An OS keyring may be used +only through an explicitly tested adapter; locked/unavailable storage refuses +the operation rather than generating a replacement identity or falling back +silently. File ownership and ACLs protect the account boundary, not against an +arbitrary process already acting as that account. Tests use temporary homes +and generated fixture keys only. + +The explicit local grant approval must validate the presented Home public key, +link identity, scopes and expiry and require the destination owner's action; +an incoming Home request cannot approve itself. For CLI-driven approval use the +existing agent-driven and interactive-consent controls +(`src/cli/agent-driven.ts:60`, `src/cli/interactive-confirm.ts:1`), with a +separately reviewed dashboard equivalent if one is added. Refuse absent or +unreadable local consent and persist the approved binding before returning +success. Revocation is an owner action and never waits for Home reachability. + +Binding migration must cover the actual writers, not just the connection type: +`commitClientConnection` and `clearClientConnection` in +`src/client/state.ts:202`, `:230`; connect/rotation and disconnect in +`src/client/connect.ts:577`, `:1193`; Child-initiated link sidecar publication +and removal in `src/client/link-join.ts:159` and `src/client/link-state.ts:129`, +`:149`; Home link creation, enrollment, compensation and revocation through +`src/server/management/link-routes.ts:99`, `:233`, +`src/server/management/link-enrollment.ts:165` and +`src/server/management/link-route-state.ts:153`. The current Child sidecar +parser has an exact field set (`src/client/link-state.ts:46`): define and test +an explicit old-record migration before adding any field, and preserve its +fail-closed marker behavior. Search all config and key writers again when the +unit begins; this list is a reviewed starting point, not proof of exhaustiveness. + +On relink, ownership change or key rotation, first persist the invalidation of +the old binding under the relevant locks, then publish the new connection; a +crash between them leaves update access disabled and requires a new local +grant. On revoke, mark the grant revoked and advance its epoch durably before +acknowledging, then stop future prepare/commit dispatch. An already accepted +operation is reconciled by its destination owner to a safe outcome; revocation +does not authorize a second installer or erase its recovery record. Status +under an invalidated binding is available only to the local owner; a stale +remote grant receives a refusal. Add focused tests for each writer, +interrupted invalidation, concurrent commit/revoke and old-record migration. + +For each operation, persist request identity and a monotonically increasing +destination operation revision before starting work. Identical retries return +the recorded outcome; conflicting reuse is refused. Cancellation and commit +compete in a single durable transition; cancellation after accepted commit is +too late, and recovery continues under the destination owner. An expired or +lost request is observed through a new status request, never blindly replayed +as a fresh install. Windows SSH currently ties the selected command to the +channel lifetime (`src/link/remote-windows.ts:37`), so a destination-owned +worker handoff must be proven on that platform before accepted work is claimed +to survive disconnect. + +The proposed lock order is `U → O → A → N → L → C → J`: existing home +sync/update mutex (`U`), proposed installation-wide ownership coordination (`O`), link apply +exclusion (`A`), native integration (`N`), client lifecycle (`L`), config +mutation (`C`), then a short journal transaction (`J`). Existing client code +documents `N → L → C` (`src/client/lifecycle-lock.ts:1`); the combined order +is a proposed extension and must be proven against every caller; `O` is not an +existing installation-wide lease. No inner lock +is held over SSH, installer execution or readiness waits. Preparation holds +only a stage lease while waiting; activation reacquires and revalidates the +relevant owners. A durable binding epoch must change before a relink, key +rotation or installation-owner change can become effective. + +Compatibility for the future Home/Child pair must be established from signed +release capability metadata for each candidate, rather than extrapolated from +current peer reports. Unknown or invalid future compatibility defers. A Child +that cannot update through its actual installer reports a specific unsupported +or owner-action state. The full installer/platform matrix and target metadata +format remain open deliverables, not a diagnostic-only completion condition. + +The signed compatibility descriptor must bind each exact release artifact and +its provided/required link protocols, update actions, recovery protocol, +installer adapter and fleet-journal reader versions. Before Home-first +activation, check the current pair, the new Home with each old Child, the new +Home with each new Child, Child recovery under the new Home, and Home recovery +after a Child has moved. Unknown pairs defer. After Home restart, obtain fresh +identity and health evidence before resuming Child commits; a healthy Home +already on the selected signed artifact can satisfy the prerequisite without a +new update. Initial rollout activates one Child at a time, and post-update +catalog sync is reported separately. + +`003_contracts.md` defines the closed descriptor and exact-pair comparison. +Add `src/link/update-compatibility.ts` and +`tests/clients/link-update-compatibility.test.ts`: consume only descriptors +validated by the shared supply parser and compare each required Home/Child and +recovery pair. Validate the Home fleet journal reader and destination adapter +for the actual operation, not merely one matching link protocol. The release +unit owns generation and parser vectors; this unit owns the comparator and +fleet gating. Older artifacts without authenticated descriptors stay unknown +until the publisher provides a descriptor bound to their exact bytes or the +owner performs an explicit manual bootstrap. This proposal does not assert +that historical descriptors already exist. + +The Home-first barrier applies to a coordinated fleet operation, not to every +independent server update. An owner may request an individual Child update +while Home remains on its current release if the authenticated descriptor +proves that current Home and both the target and recovery Child are compatible. +The Child may also follow its own enabled signed channel and idle policy while +the Home is offline. Such work retains destination consent, signed supply, +durable operation identity and guarded activation. This preserves the +independence documented by GOAT (`docs/internals/remote-updates.md:21`, GOAT +reference revision); lack of a live Home is not permission to weaken those +local checks. Fleet Home-first resumption and individual Child operation need +separate tests and status. + +Independent automatic updates may race, so a comparison of only today's peer +versions is insufficient. For an offline or uncoordinated move, require both +the currently installed artifact and its target/recovery artifacts to carry the +same publisher-authenticated `compatibilityEpoch` and +`compatibilityVectorSha256` described in `003`; each endpoint retains the +last verified peer epoch/vector and installation binding. This is a +publisher-certified invariant over every artifact in that epoch, not a claim +that a peer observation is fresh. A target that changes either value cannot +activate independently: it must use the coordinated fleet transition, which +holds the fleet generation lease and checks all transitional/recovery pairs. +An unknown, revoked or unavailable epoch also defers. A current peer reading +is still required before a coordinated commit; an old authenticated reading is +not treated as current. An operator may perform a separately authorized manual +recovery if coordination is unavailable, without weakening signature or owner +checks. + +The release build must derive the compatibility vector from the owning feature +and protocol declarations and prove the epoch invariant in release tests; a +publisher must not reuse an epoch after a compatibility change. Add +`tests/clients/link-update-compatibility.test.ts` cases where Home and Child +simultaneously validate their old peer and select individually compatible but +mutually incompatible targets: changed epochs must refuse independent +activation. Also test matching epoch/vector with an offline Home, stale or +revoked descriptors, rollback targets and a coordinated breaking transition. +Before a coordinated epoch change, the Home durably records the transition and +obtains an authenticated generation-bound update fence from every participating +Child. Each Child closes new independent admission, reports and reconciles any +already accepted operation, and retains that fence until the Home commits or +cancels the same fleet generation. An offline or uninspectable participant +blocks that breaking transition; the coordinator must not infer an ack from a +stale report. Lost acknowledgements are read back against the same generation, +and a restarted coordinator reconciles the durable fences before dispatching. +Test an independent update accepted just before the fence, one racing it, a +lost acknowledgement and offline participants. Existing same-epoch automatic +updates can resume only after their local fence is released or reconciled. +This permits independent updates only where the signed compatibility horizon +proves the resulting cross-product, and remains a design pending security and +release review. + +The proposed Home journal is `/link/fleet-updates.sqlite`; the +Child journal is `/update/link-operations.sqlite`. Both use private +permissions and durable transactions. The Home stores fleet ID, generation, +frozen member/link identities, exact artifact digests, Home health barrier, +per-Child operation IDs, request digests, latest authenticated result and +separate sync outcome. The Child stores grant/binding epochs, operation sequence, +request digest, local policy revision, prepared-stage identity, durable phase, +installer/stop receipts and outcome. Request or result secrets are not logged. +Home startup takes one fleet lease, reconciles unresolved intents before +dispatch, re-proves its own target/health and establishes a fresh barrier; +Child startup reconciles its own unfinished operation before accepting another. +The initial implementation must prove SQLite durability and platform file +ownership using isolated crash/restart tests, not just schema serialization. + +## Existing owners and candidate change map + +| Operation | Path | Before → proposed after | +| --- | --- | --- | +| MODIFY | `src/link/compatibility.ts`, `src/remote/features.ts`, `src/link/peer-report.ts` as needed | Reuse authenticated relay and peer-feature compatibility (`compatibility.ts:71`); advertise a versioned update capability only when it is actually implemented. Unknown and older peers stay readable but are not admitted to unattended replacement. | +| NEW / MODIFY | `src/cli/link-update-command.ts`, `src/cli/dispatch.ts`, `src/cli/registry.ts`, `src/cli/capability-contracts.ts`, `src/cli/capabilities-base.ts`, `skills/ocx/` | Add separate local grant and remote operation entry points, advertise `link-update/1` only when implemented, and regenerate the CLI skill surface. Preserve existing command output and use the established person-consent controls for grant changes. | +| MODIFY | `src/client/connect.ts`, `src/client/state.ts`, `src/types/config.ts` and all connection-state readers identified by search | Create and persist a binding epoch, parse older records safely, invalidate grants before connect/disconnect/rotation or installation ownership changes, and recheck the same binding under existing locks. The full writer/consumer list and migration tests must be enumerated before implementation. | +| NEW / MODIFY | `src/link/` update orchestration sibling, `src/cli/sync-fanout.ts`, `src/cli/sync-command.ts` | Existing fan-out selects known linked Children and bounds work (`sync-fanout.ts:99`, `sync-command.ts:207`). Reuse identity, deadline and per-child verdict patterns while keeping update and catalog sync separate contracts. Establish whether a shared runner is appropriate before editing. | +| MODIFY | `src/link/ssh-argv.ts`, `src/link/remote-windows.ts` only after design approval | For Home-initiated SSH, select the existing pinned executable, frame its output and carry an exact validated update request; never interpolate untrusted shell text. Preserve existing `sync --expect-link` behavior. | +| MODIFY | `src/update/orchestrated-plan.ts`, `src/update/orchestrated-entry.ts`, `src/update/orchestrated-admission.mjs` only where required | Existing plan rejects ambiguous ownership, source/desktop/mise installs and downgrades (`orchestrated-plan.ts:21`); reuse plan token, deadline, installer and observed-result contracts, adding exact verified artifact and destination-bound authorization without weakening them. | +| NEW | Destination-owned policy, operation journal and admission modules under the established owners | Persist explicit local enable/defer policy and bounded status. Recheck link identity, consent, signed target, compatibility and idle immediately before mutation. The owner and wire schema need architect and security review. | +| NEW / MODIFY | `desktop/src-tauri/src/link_update_bridge.rs`, native update owner in `desktop/src-tauri/src/updater.rs`, `src/link/` destination worker | A destination-owned Desktop app accepts only a locally granted, operation-bound request and applies it through its existing native updater, consent policy and Stop/Quit-aware recovery. A package worker never replaces a Desktop bundle. An offline app, absent local grant or unverifiable app identity returns owner-action/unknown; completing this unit requires supported remote Desktop activation, not a permanent refusal. | +| NEW / MODIFY | `src/server/management/link-update-routes.ts`, `src/server/management-api.ts`, `src/server/management/route-registry.ts`, `gui/src/remote-link-update-api.ts`, `gui/src/pages/RemoteLink.tsx`, `gui/src/components/remote-link-update-panel.tsx`, all GUI locales and relevant docs-site locales | Register the separate versioned status/action endpoints below, parse their DTOs in the GUI and render destination status, retry and Later. Preserve `parseRemoteLinkStatus` and the old `/api/link/status` response. Never claim completion until destination evidence proves version and health. | +| MODIFY | `tests/cli/sync-fanout.test.ts`, `tests/clients/link-peer-compatibility.test.ts`, focused `tests/update/` and `tests/clients/` | Cover authenticated identity, mixed old/new peers, Home/Child ordering, duplicate requests, deadlines, signed payload mismatch, busy/idle races, recovery and sync interaction using isolated fixtures. Register new domain test files in both layout maps. | +| MODIFY | `structure/remote-link.md`, `structure/remote-messaging.md`, `structure/ops/service-and-sidecars.md`, other source owners from the index | Review both `src/link/` owners (`structure/INDEX.md:366`); record actual post-change ownership, wire schema, compatibility, outcomes and invariant tests in affected docs. | + +### Dashboard management boundary + +`src/server/management/link-update-routes.ts` owns separately registered +`GET /api/link/updates/v1/status`, `POST /api/link/updates/v1/action` and +`GET /api/link/updates/v1/action-result?requestId=`; +`src/server/management-api.ts` dispatches the sibling through its existing +on-demand management composition and `route-registry.ts` records both methods +and the mutation. Both require the existing operator-paired dashboard session +(`link-route-state.ts:67`); reject admin-token-only, unpaired, Tailscale +identity, passive relay and snapshot-capability callers. Keep existing CSRF, +origin, body and mutation controls, and prove the new handler is never exposed +through the read-only link management relay. Neither endpoint can approve, +create, widen or revoke a destination grant or alter destination policy. + +Status returns a closed `ocx.link-update.dashboard-status/1` object with +`schema`, `fleetId`, `fleetRevision`, `destinations` and `nextCursor`. A page +contains at most 100 destinations; `nextCursor` is null at the end or an opaque, +bounded server-issued cursor bound to fleet ID/revision and this paired session. +The only optional query is `cursor`; stale or invalid cursors refuse rather than +mixing revisions, and the GUI restarts the read on a changed fleet revision. +Each item has +`linkId`, `bindingEpoch`, `operationId`, `operationRevision`, `candidateId`, +`destinationPolicyRevision`, `lastActionRequestId`, `phase`, `reason`, `commitAccepted`, +`mutationStarted`, `installationVerified`, `runtimeHealthy`, and `deferred`. +Nullable identities and outcomes mean unknown, never success. `phase` is one +of `unknown`, `unavailable`, `eligible`, `preparing`, `ready`, `deferred`, +`committing`, `installed`, `healthy`, `failed`; `reason` is a bounded closed +code, not raw remote errors. Values come from the local durable Home journal +and authenticated Child result; the DTO is display evidence only. A stale or +offline Child remains unknown/unavailable and cannot be called healthy. +`lastActionRequestId` is null or the newest journaled dashboard request for the +link, including an unresolved request. It lets a newly opened GUI recover the +request identity after a tab or Home restart; it is not an action capability. + +The closed action request `ocx.link-update.dashboard-action/1` contains +`schema`, fresh UUID `requestId`, `action` (`retry` or `later`), `linkId`, +`bindingEpoch`, `operationId`, `expectedOperationRevision`, `candidateId` and +`destinationPolicyRevision`. Require exact keys, bounded bodies and matching +journal/binding/policy revisions; null operation/candidate is rejected for an +action. `later` durably suppresses this candidate for that destination until an +explicit retry or a different verified candidate; it never cancels a committed +handoff or changes the Child's policy. `retry` clears that local deferral only +after rechecking destination grant, local policy, compatibility, candidate, +identity and admission; a new operation is allowed only after reconciliation of +the old one. The response contains `schema`, `requestId`, `operationId`, +`operationRevision`, `disposition` (`pending`, `accepted`, `deferred`, `refused` +or `uncertain`) and `reason` (a bounded code or null), with literal schema +`ocx.link-update.dashboard-action-result/1`. It is an acknowledgement, not proof of +installation. Before external effects, atomically persist `requestId`, SHA-256 +of the exact accepted request bytes, original operation ID A, destination/link +and binding identity, and a pending receipt. The journal then records resulting +operation ID B (or null), resulting revision and disposition. On repeated POST, +look up request ID and compare the exact-body digest before stale-revision +checks: identical bytes return the existing pending or recorded receipt only; +a different digest refuses. Neither replay nor a readback may issue an effect. +First execution still validates current grant, policy, binding, candidate, +operation revision and owner before dispatch. A crash between journal and +dispatch is reconciled by the durable Home owner and destination protocol; +pending does not mean the effect did or did not occur. + +The separate `action-result` GET requires a newly authenticated paired session, +accepts exactly one UUID `requestId` and returns a closed bounded +`ocx.link-update.dashboard-action-readback/1` object: `schema`, `requestId`, +`requestDigest`, `linkId`, `bindingEpoch`, `originalOperationId`, +`resultingOperationId`, `operationRevision`, `state` (`pending`, `recorded`, +`unknown`) and `disposition`/`reason` (nullable until recorded). For `unknown`, +all evidence fields except `schema` and requested `requestId` are null. A known +request is looked up from the durable Home journal, not from the currently +observed peer; after relink or grant revocation it is historical evidence only. +Unknown, expired or pruned records return null evidence and `unknown`, never +evidence that an effect did not occur. Retain unresolved requests and their +operation bindings until reconciliation; a bounded terminal-retention policy +may prune only settled receipts. After a lost response, GUI reload, Home restart +or session replacement, the GUI recovers `requestId` from its pending request or +the status projection, reads that exact request, then observes resulting B if +known. An unresolved or unknown result disables further effectful retry until +owner reconciliation; no mere possession of the request ID grants authority. + +`gui/src/remote-link-update-api.ts` strictly parses these separately versioned +objects; `RemoteLink.tsx` renders a sibling +`gui/src/components/remote-link-update-panel.tsx` keyed by link and operation, +without appending fields to `parseRemoteLinkStatus`. On a 404 or unsupported +schema from an older server, show an unavailable state and disable actions; +never infer success. Unit `080` uses this same Home-side DTO after its own +Child-pull authority and durable operation contract are implemented. Add +`tests/server/link-update-dashboard-routes.test.ts`, +`tests/clients/link-update-dashboard.test.ts` and +`gui/tests/remote-link-update-panel.test.tsx` as future tests, with the two +root-test layout registrations where applicable. Cover paired versus unpaired, +admin and relay rejection, malformed and stale revisions, old servers, mixed +links, offline status, Later persistence, revocation/policy change, duplicate +requests, A→B Retry with a lost acknowledgement, identical replay before stale +revision rejection, conflicting request bytes, pending/unknown retention, +repaired sessions, Home restart, pagination and both SSH and Child-pull +transports, and translated accessible +states. A reachable implementation and these tests require separate security +review; these DTOs themselves are not authorization. + +The Desktop bridge is a separate local owner IPC, not a call to privileged +WebView commands. The native app creates an owner-private endpoint under its +validated update-operation directory: a Unix-domain socket on macOS/Linux or a +current-user-ACL named pipe on Windows. Its private identity record binds app +PID/process instance, install ID, endpoint and a fresh secret; the destination +worker validates file ownership, challenges the live app and binds the reply to +the same connection and process instance before sending the already signed, +bounded request. The app verifies the destination-local grant, link binding, +policy, candidate and operation revision again, then invokes its existing +native update owner; neither the IPC caller nor the dashboard gets an install +path or verification bypass. + +Each IPC request carries operation ID/generation and a nonce plus a body-bound +capability derived from the private app secret; the reply binds that request +digest, nonce and native owner generation. Record `accepted` durably before a +success reply. Repeated identical requests read the same operation; changed or +stale requests refuse. The app and its separately signed guardian preserve the +journal and reconcile after application exit; a lost IPC reply is observed via +the destination journal, not retried as a second install. If the app is not +running or its identity/endpoint is unknown, return owner-action/unknown; do +not start or replace it as a package. Add isolated POSIX and Windows IPC tests +for forged or stale app identity, unauthorized callers, changed grant/policy, +lost replies, process exit, concurrent native-page/remote actions and Stop/Quit +intent. The IPC boundary is separate from the bundled update-page guard +(`desktop/src-tauri/src/lib.rs:272` at the inventory baseline) and requires +independent security review. + +## Required activation scenarios + +1. For a fleet operation, Home is updated and proven healthy before Child + commits; for an independently requested Child operation, the current Home and + target/recovery compatibility can authorize the Child without updating Home. + Each eligible Child uses its own exact target and independently verifies the + signature. Catalog synchronization remains separately reported. +2. A disabled or deferred Child, changed link identity, disconnected Child, + unknown capability, incompatible protocol, source/mise-owned install, + missing signature or target downgrade produces a specific non-success state + without touching the installation. Device-side consent is not inferred from + Home control alone. +3. An active request or unknown idleness defers. If idleness, identity, policy, + target, owner or plan token changes after planning, a fresh admission refuses + before any mutation; the existing stop/drain path never interrupts a request. +4. A timeout or disconnected client cannot be called success. A Child that + continues after a lost reply is observed rather than blindly retried; a + failed or rolled-back target is not automatically retried in a loop. Result + readback verifies version and health, with explicit uncertain outcomes. +5. Old peers and malformed/unknown capabilities cannot acquire update authority; + standard sync remains functional. Exercise POSIX and Windows command framing, + duplicate and partial outputs, concurrent operations and isolated fake feeds. +6. A linked Desktop owner applies an authorized request only through its native + updater; missing local consent, a stopped/offline app, changed native owner or + busy activity is reported without a package installer taking over. Test both + successful native delegation and refusal, including Stop/Quit intent. + +## Candidate verification + +Run red-to-green focused fan-out, compatibility, orchestrated-update and new +integration tests, including subprocess/source-read files explicitly. Run +`bun run typecheck`, `bun run privacy:scan` and `bun run structure:check`; +validate relevant GUI locales/build if visible UI changes, and obtain a separate +independent security PASS. Exact commands and evidence that they reach the +changed paths must be established before locking the plan. No full root suite; +required exact-head PR CI and any authorized platform proof remain mandatory. diff --git a/devlog/_plan/261107_auto_update_parity/070_standalone_updates.md b/devlog/_plan/261107_auto_update_parity/070_standalone_updates.md new file mode 100644 index 00000000000..ed28a54bcbf --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/070_standalone_updates.md @@ -0,0 +1,66 @@ +# Signed standalone updates — draft unit + +**Status:** depends on signed supply, update-stop admission, package/guardian +recovery and linked update contracts. The current orchestrated plan maps a +non-Desktop standalone installation to unknown (`src/update/orchestrated-entry.ts:128`); +this unit must not change that refusal until the new owner is proven. + +## Outcome and proposed file map + +Support an explicitly owned standalone executable on the existing release +targets (`scripts/standalone-targets.ts:7`). The owner downloads a signed exact +platform artifact, verifies it and a signed previous recovery artifact, stages +both outside the live executable and uses the same policy, idle reservation, +receipt-backed stop and durable recovery contracts as the package unit. +Unknown, foreign, source and externally managed paths remain refused. + +| Operation | Proposed path | Required change | +| --- | --- | --- | +| NEW | `src/update/standalone-owner.ts`, `src/update/standalone-stage.ts` | Resolve and bind the selected real executable and install owner; verify the signed exact target and recovery material; expose an owner-held stage without executing downloaded bytes. | +| NEW | `src/update/standalone-replace.ts` and a signed recovery helper | Perform a same-owner replacement and rollback using existing mutation controls and the update-stop authority that `020` must first design and prove. On Windows, use a verified external helper that waits for the executing image to exit; no uncontained replacement or unverified helper. | +| MODIFY | `src/update/orchestrated-plan.ts`, `src/update/orchestrated-entry.ts`, `src/cli/capability-contracts.ts` | Advertise a separate versioned automatic capability only after its owned standalone path is implemented; preserve existing `ocx.update.plan/1` and result behavior. | +| MODIFY | `scripts/build-standalone.ts`, `desktop/scripts/verify-release-assets.ts`, release workflow within its existing permissions | Produce and verify signed target/recovery artifacts and the exact compatibility metadata. No release is published by this task. | +| NEW | `tests/update/update-standalone-automatic.test.ts`, `tests/cli/cli-standalone-update-owner.test.ts` | Domain-registered, isolated fixtures for signed staging, owner changes, idle admission, process termination, crash at each swap, rollback and health. | +| MODIFY | `structure/runtime.md`, `structure/ops/service-and-sidecars.md`, relevant release and user docs plus translations | Describe actual supported owners, platforms and recovery. | + +The standalone build is a multi-file installation: it includes the compiled +executable, a target-specific native keyring addon and `gui/dist` +(`scripts/build-standalone.ts:84`, `:95`). Replacing only the executable does +not establish a matching signed release. The selected adapter owns an explicit +managed installation directory containing versioned complete signed generations +and a stable launcher/selection record; it verifies the complete inventory, +including the addon and GUI, before a cutover. A legacy loose executable or +foreign directory first needs an explicit owner-guided bootstrap into that +layout; do not infer ownership or delete neighboring files. The bootstrap and +its failure/recovery tests are part of this unit, not a deferred exclusion. + +Use the signed guardian and operation journal from `030`: stage an exact target +generation and separately authenticated prior generation, acquire the `020` +installation fence, prove runtime/process exit, and publish one durable +generation selection under the owner. On POSIX, prove same-filesystem atomic +publication and recovery of the selection; on Windows, the verified guardian +waits for the executing image and observes its own replacement operation before +readback. If the platform cannot establish selection atomicity or durable +recovery, refuse that owner's automatic update and keep its parity row open. +Validate the real launcher, selected generation, native addon, GUI and serving +version together after restart; a matching executable version alone is not +health. Tests cover loose-layout bootstrap, unrelated neighboring files, +cross-volume refusal, interrupted selection, guardian death and signed prior +generation restoration on every supported release target. + +## Activation and evidence + +- Valid signed exact-platform bytes stage in a temporary owner-controlled home; + missing/wrong signatures, wrong architecture, altered stage, ambiguous owner + and absent signed recovery material refuse before runtime stop. +- Busy or unknown activity defers. A successful owner-bound stop precedes any + replacement, and the previous executable remains recoverable until target + health and the probation period complete. +- Simulate interruption before/after each durable journal, rename or helper + boundary, lost response and surviving process. Verify either the previous + signed executable is restored or an honest uncertain state is retained; + never run a second installer on an unconfirmed outcome. +- Exercise each supported OS/architecture through isolated tests and relevant + native CI. Require the separate security PASS, typecheck, privacy/structure, + Lab boundary, test-layout/file-size guards and exact-head CI. Commands and + platform-specific helper implementation remain to be concretized and audited. diff --git a/devlog/_plan/261107_auto_update_parity/080_child_initiated_updates.md b/devlog/_plan/261107_auto_update_parity/080_child_initiated_updates.md new file mode 100644 index 00000000000..3d1e6ee226c --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/080_child_initiated_updates.md @@ -0,0 +1,108 @@ +# Child-initiated linked updates — draft unit + +**Status:** depends on the signed supply, destination grant and durable update +operation in `060`. Home-initiated SSH alone does not cover this link direction. +Do not treat the current `transport-unsupported` state as final parity. + +## Outcome and proposed transport + +For a Child-initiated link with no Home-to-Child SSH exec route, the proposed +Child would poll its authenticated Home connection for a request addressed to +its locally approved update grant. The existing passive management relay stays +read-only. A separate versioned exchange would carry bounded signed requests +and results; the Child would check its grant, policy, candidate, installer +ownership and idle admission before any effect. Home could request but not +approve or widen the destination grant. Existing link sync would remain a +separate operation and status. + +The existing Home listener combines its route allowlists in +`src/server/index/optional-listeners.ts:90` and admits the configured link key +before dispatch (`src/server/index/serve-options.ts:387`). The Child's +`fetchBoundLinkRelay` authenticates the Home and sends on the same socket +(`src/client/link-relay-transport.ts:240`); the existing parity reporter is a +bounded Child-to-Home POST example (`src/client/link-home.ts:344`). These are +transport precedents, not update authorization. In particular, the Home +management relay remains a separate read-only allowlist. + +Proposed exchange uses two exact Home link-listener routes, both POST with no +query, upgrade or redirects: `/v1/link-update/poll` and +`/v1/link-update/result`. Add an update-specific route predicate and dispatch +sibling; do not add either route to `link-management` or the Child's local data +ingress. Before the handler, retain the current link-key admission and require +its configured key ID to match the current link record. The handler then +verifies the separately enrolled destination response key and grant/binding +epochs from `060`; link-key possession alone cannot select or acknowledge an +operation. Unknown route, old peer, missing grant, revoked binding, stale key or +unreadable state returns unavailable and causes no update effect. + +`ocx.link-update.poll/1` contains `requestId`, random `nonce`, `issuedAt`, +`notAfter`, `childId`, `installationId`, `linkId`, `linkBindingEpoch`, +`grantId`, `grantEpoch` and the latest observed operation ID/revision or null. +The destination signs the exact bytes with its enrolled response key using a +separate poll domain; the bounded envelope follows `060`. A valid response is +`ocx.link-update.poll-result/1` with the poll request digest and nonce, +destination/grant bindings, server time and either null or one already journaled +Home-signed `ocx.link-update.envelope/1`. The Home signs the exact response +bytes with its enrolled update key. The Child verifies the response, then the +contained request's independent Home signature, action, expiry, local grant, +policy, exact candidate and current binding before recording or performing it. +The Home may not use the poll response to widen the destination grant. + +`/result` carries a destination-signed `ocx.link-update.result/1` from `060`, +including the originating request digest, nonce, operation and revision; the +Home stores it only after signature, admitted key, current binding and journal +intent all match. Repeated identical results are idempotent; a conflicting +revision is refused. Return only a bounded acknowledgement bound to that result +digest. Limit each envelope and response to 64 KiB, decoded payload to 32 KiB, +input/read time to five seconds, and signed request validity to five minutes. +No endpoint returns local paths, credentials or arbitrary execution commands. + +The destination poller starts only for an active Child-initiated connection +with a locally enabled grant. Use one in-flight poll, a 30-second normal +interval and bounded 5/15/30/60-second retry backoff; stop on disconnect, runtime +shutdown or revoked grant. Reconnect first reconciles its durable operation and +binding before polling again. A lost poll response is retried with a fresh poll +nonce; a delivered operation is deduplicated by its journal identity, not +re-executed. Accepted installation work is handed to the same destination-owned +worker and recovery contract as `060`, independent of the tunnel lifetime. +No status changes to healthy until the destination observes the exact installed +target and owned serving runtime. Exact worker startup and platform survival +remain a proof requirement, especially for Windows channel containment. + +| Operation | Proposed path | Required change | +| --- | --- | --- | +| NEW | `src/link/update-exchange.ts`, `src/client/link-update-poller.ts` | Dedicated authenticated POST poll/result exchange and Child-owned bounded poller. The poll reads only a previously journaled request; result publication uses its own authorized operation, not management relay writes. | +| MODIFY | `src/server/index/optional-listeners.ts`, `src/server/index/serve-options.ts` (extract an owned sibling if ratcheted), `src/remote/features.ts` | Admit the two exact exchange paths only on the authenticated Home link listener, dispatch with the configured key identity and advertise the capability only when implemented. Preserve existing connection-bound relay authentication and passive-read routes. Unknown peers and absent grants remain unavailable. | +| MODIFY | Destination and fleet journals from `060` | Persist request intent and outcome across Home/Child restart; reconcile the same operation after response loss, expiry or reconnect. Recheck binding epochs, local policy and owner before commit. | +| MODIFY | `src/server/management/link-update-routes.ts`, `gui/src/remote-link-update-api.ts`, `gui/src/components/remote-link-update-panel.tsx` | Project Child-pull operations through the same `060` versioned dashboard status/action/readback DTOs; add old-peer, disconnect, reconnect, Retry A→B with lost reply and uncertain-result cases without granting the passive relay write authority. | +| NEW | `tests/clients/link-update-child-initiated.test.ts`, `tests/server/link-update-exchange.test.ts` | Isolated linked homes, fake transports and signed test artifacts: no grant, stale link, duplicate delivery, lost responses, busy/unknown runtime, cancellation, crash recovery and separate sync results. Register both domain files in the two test-layout maps. | +| MODIFY | `structure/remote-link.md`, `structure/remote-messaging.md`, relevant user docs and all affected translations | Record implemented transport, policy, prerequisites and actionable unsupported cases. | + +## Activation and evidence + +Prove that a Child with no locally approved grant receives no installation +authority, that Home cannot change Child policy, and that duplicate or late +requests cannot start a second install. A signed exact candidate and future +compatibility proof are necessary but not sufficient: the Child independently +rechecks its own owner, current policy and idle reservation. A reconnect or +credential change invalidates stale operation bindings. Network loss leaves +accepted work under its destination-owned recovery; status is not reported +healthy until exact target and runtime health are observed. + +Use fake links, isolated homes, test keys and a local mock feed. Run failing- +first focused protocol, grant, replay and recovery tests; then typecheck, +privacy/structure, Lab boundary, layout and exact-head CI. Obtain a separate +independent security PASS. The exchange uses the `060` `U → O → A → N → L → C +→ J` order: poll/result network I/O holds none of those locks; the destination +checks and commits journal/binding state in short transactions and reacquires +activation authority before mutation. This proposed order and destination +worker survival still require call-graph, crash and platform proof before audit. + +Baseline on 2026-11-07: `bun test tests/server/sidebar-routes.test.ts +tests/server/link-management-relay.test.ts` passed 31 tests (exit 0). The relay +suite starts isolated loopback listeners and verifies that existing management +reads remain passive and reject writes. It does not exercise the proposed +update exchange. The two new test files in the file map do not exist yet; run +them with `bun test tests/clients/link-update-child-initiated.test.ts +tests/server/link-update-exchange.test.ts` only after creating and registering +them, and record initial red and final green separately. diff --git a/devlog/_plan/261107_auto_update_parity/090_pnpm_updates.md b/devlog/_plan/261107_auto_update_parity/090_pnpm_updates.md new file mode 100644 index 00000000000..8693a970495 --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/090_pnpm_updates.md @@ -0,0 +1,112 @@ +# Signed pnpm-owned updates — draft unit + +**Status:** follows the common signed supply, idle admission and npm-owned +stage/recovery work. The existing pnpm updater mutates its selected global +owner through pnpm and uses pnpm again for rollback +(`src/update/pnpm-global-install.mjs:464`, `:513`, `:564`). A signed npm-shaped +tree cannot be renamed into pnpm's virtual store and treated as pnpm ownership. + +## Proposed implementation slice + +1. Add `src/update/pnpm-prepared-install.mjs` and declaration sibling. Resolve + the existing `PnpmGlobalOwner`, its global root, bin root, virtual-store and + lock inputs through the existing owner helpers; refuse ambiguous or changed + ownership. Bind those facts and the exact signed candidate to an owner-held + operation record. +2. Build a disposable pnpm global layout from the verified local signed + dependency closure with lifecycle scripts disabled and network unavailable. + Compare all resulting package/store entries and generated bin links against + the signed inventory and the selected owner. Never write to the live global + group during preparation. The exact pnpm invocation and relocatable layout + are an explicit experiment and audit blocker, not a presumed supported flag. +3. Before activation, reacquire the existing home sync/update guard and service + ownership lease. Obtain installation-wide coordination only after `020` + designs and proves it; revalidate policy, stage and selected pnpm owner, then use + the `020` update-stop reservation. Use a manager-mediated selected-owner + transition, journaled before each operation; do not describe it as an atomic + group/bin swap. Bind and verify the generated shims, selected final store, + complete publisher inventory and process exit before declaring installation. + Keep signed previous archives, store identity and metadata for recovery until + target health and probation complete. +4. Extend `src/update/orchestrated-pnpm.mjs` and the automatic coordinator with + a separately versioned prepared operation; preserve the existing explicit + pnpm command and strict output schemas while requiring verified supply. + Unsupported versions/layouts refuse + with an actionable state rather than falling back to an online install. + +Modify `tests/update/update-pnpm.test.ts` and add domain-registered +`tests/update/update-pnpm-prepared.test.ts` plus any required platform fixture. +Use temporary global roots, a local test key, fake or isolated manager process +and network-denial assertions. Exercise missing/altered artifacts, lifecycle +script refusal, symlinks/shims, changed global owner, uncertain process exit, +concurrent sync, crash before/after group transition, verified rollback and +health. No test touches the real global installation. + +Update `structure/ops/service-and-sidecars.md`, other mapped owners and user +docs/translations. Run red-to-green focused tests, typecheck, privacy/structure, +test-layout/file-size and Lab boundary checks, relevant native/CI coverage, +and a separate independent security PASS. Actual supported pnpm versions, +offline preparation command, platform layout, writer exclusion and durable +manager-mediated transition must be proved before automatic activation. + +## Isolated feasibility observation + +On 2026-11-07, the host's pnpm reported `12.18.0`. A macOS `sandbox-exec` +fixture denied network and writes outside its private temporary root; separate +probes confirmed those denials. Adding a local inert fixture with +`--global --ignore-scripts --ignore-pnpmfile` and a fixture-owned store did not +run its postinstall. The first attempt refused because its isolated bin path +was not on `PATH`; after correcting that path, pnpm exited 1 with +`ERR_PNPM_STORE_DIR_OPEN_OPERATION_LOCK` / `Operation not permitted`, including +when the fixture state directory was supplied. No real global install was +touched. This shows only that this first sandbox configuration did not complete +the operation; it does **not** establish a pnpm defect or prove pnpm cannot +stage offline. A later run with fixture-owned `XDG_RUNTIME_DIR`, store and state +paths exited 0 without running the postinstall. The decisive configuration +change and original lock location were not established. + +A separate probe used the already-installed pnpm `11.10.0` directly, with a +fresh fixture-owned global home/store, `--global --offline --ignore-scripts +--ignore-pnpmfile`, and the same network/outside-write sandbox. It exited 0, +and the inert fixture's postinstall sentinel remained absent. The resulting +global group contained a package symlink into the separate fixture store and +generated bin files. This is positive evidence for one local no-dependency +fixture only: it does not establish relocatability, signed dependency closure, +atomic live-group activation or recovery. A further pnpm 11 fixture changed +versions 1.0.0 → 1.1.0 → 1.0.0 offline with the sentinel absent. Copying its +global home without the separate store broke the package link; its generated +bin script still pointed into the original fixture. These observations do not +prove a safe relocation or a crash-safe live update. + +Pre-audit research investigated a manager-supported transition and recovery +with a locally signed dependency fixture. Exact commands, publisher-signed +versus manager-generated bytes, store/bin ownership, interruption and cleanup +evidence are retained under `.tmp/`; no real install was touched. + +The follow-up isolated investigation on macOS arm64 with pnpm 11.10.0 used a +signed local two-package fixture, a private store kept at its final path and a +selected-owner offline manager re-add. Preparation, activation and restoration +completed in that fixture; three controlled process interruptions exposed +intermediate group/bin states and the previous signed fixture was subsequently +restored. This selects manager-mediated re-add plus a durable owner-held journal +as the implementation direction, **not** an atomic switch or proof of safe +production activation. Bind exact signed prior/candidate archives and store, +global and bin identities, recheck policy/owner/idle, prove process-tree exit, +verify the complete installed graph and generated wrappers before any candidate +execution, and reconcile every interrupted transition before another installer. +Require installation-scoped exclusion from `020`, safe owned cleanup, verified +recovery, real OpenCodex executable closure and platform/version tests. No +unproven layout is automatically eligible; the detailed negative evidence +remains in the private research packet. + +For the observed pnpm 11.10.0 fixture, classify the global manifest, lock, +`.modules.yaml`, manager index and global/internal bin wrappers as generated +state, separate from signed package files. The adapter permits only the +version-specific schema and exact wrapper templates it has tested, with each +resolved command and symlink bound to the selected final store and signed +payload. Other pnpm versions, store formats, wrapper forms or platforms remain +unavailable until their own fixtures prove the same contract. Persist the +validated generated-state digest for drift/recovery readback; do not accept +unknown files merely because they are under the store. Test changed metadata, +unexpected executable/link, owner path changes and manager-specific recovery +without executing candidate code until the full output verifies. diff --git a/devlog/_plan/261107_auto_update_parity/100_bun_updates.md b/devlog/_plan/261107_auto_update_parity/100_bun_updates.md new file mode 100644 index 00000000000..5d7bd5a7616 --- /dev/null +++ b/devlog/_plan/261107_auto_update_parity/100_bun_updates.md @@ -0,0 +1,93 @@ +# Signed Bun-owned updates — draft unit + +**Status:** follows the common signed supply, idle admission and npm-owned +stage/recovery work. The current Bun-owned update runs a global add +(`src/update/index.ts:354`); that is not evidence of independently staged, +publisher-verified activation or rollback. + +## Proposed implementation slice + +1. Add `src/update/bun-prepared-install.ts` and an owner record for the selected + Bun global package root, bin links, global manifest/lock state and exact + signed candidate. Use `detectInstallOwnership` and fail closed on unknown, + foreign or changed ownership (`src/update/orchestrated-plan.ts:21`). +2. Materialize the verified signed executable closure into a disposable Bun + global layout with scripts disabled and network denied. Bun's installed + help exposes `--offline` and `--ignore-scripts`, but the exact invocation, + global metadata behavior and relocation semantics must be proven in isolated + fixtures before they are claimed as supported. Validate every installed file + and link against signed inventory; preparation must not write to the live + global tree or execute a downloaded helper. +3. At activation reacquire the existing home sync/update guard and service + ownership lease. Obtain installation-wide coordination only after `020` + designs and proves it; revalidate stage, policy and owner, then obtain the `020` + idle update-stop reservation. Journal a manager-mediated selected-root + transition and verify its package, metadata and bin links before execution; + do not claim that the manager changes those files atomically. Retain the + signed previous installation until health and probation complete. +4. Extend `src/update/orchestrated-entry.ts` and the automatic coordinator with + a separately versioned prepared operation. Preserve current explicit update + output and existing ownership/supervisor refusals while requiring verified + supply for installation. Never fall back to an + unverified global add when offline preparation or rollback cannot be proven. + +Add domain-registered `tests/update/update-bun-prepared.test.ts` and use +isolated homes, a signed test closure, fake manager workers and network denial. +Cover changed owner, global lock/bin drift, script and unsigned dependency +refusals, expired stage, interrupted worker, rollback after each mutation, +health readback and concurrent sync. Update `structure/runtime.md`, +`structure/ops/service-and-sidecars.md` and affected user docs/translations. + +Run failing-first focused tests, typecheck, privacy/structure, test-layout and +file-size gates, the Lab boundary test, relevant platform CI and a separate +independent security PASS. Actual Bun global layout, cache isolation, +manager-compatible durable transition and signed rollback remain proof blockers +before this unit can be enabled. + +## Isolated feasibility observation + +On 2026-11-07, Bun `1.4.4` installed an inert local fixture into an isolated +`BUN_INSTALL` root with `--global --offline --ignore-scripts --no-env-file`; +the command exited 0 and the fixture's postinstall sentinel was absent. A +macOS `sandbox-exec` profile denied network and writes outside the temporary +root, and separate probes confirmed those denials. The observed output included +a global manifest/lock, a nested package directory and a relative bin symlink. +This proves only that one local package can be prepared without its lifecycle +script in that fixture. A separate offline fixture round trip changed versions +1.0.0 → 1.1.0 → 1.0.0 with the sentinel absent. Copying the global root +retained an absolute `file:` source in its manifest, so naïve relocation was +not established. These experiments do not prove signed dependency closure, +atomic activation, verified recovery or runtime health. + +Pre-audit research investigated a manager-supported transition and recovery +with a locally signed dependency fixture. Exact commands, publisher-signed +versus manager-generated bytes, root/bin ownership, interruption and cleanup +evidence are retained under `.tmp/`; no real install was touched. + +The follow-up isolated macOS arm64 investigation with Bun 1.4.4 used a signed +local two-package fixture and showed offline selected-owner install and +restoration, plus `--lockfile-only` preparation followed by frozen +materialization. A separate Bun 1.4.3 fixture did not satisfy the selected-root +contract; it does not establish a general supported-version floor. Interrupted +and competing manager operations showed that package, metadata and bin changes +cannot be treated as one atomic transition. The selected direction is a +journaled manager-mediated operation under the installation-scoped exclusion in +`020`, with exact signed prior/candidate inputs, bound root/bin/cache identities, +complete output/link verification before execution and verified recovery. +Resolve custom global roots explicitly, preserve unrelated global packages and +retain operation-owned recovery material through probation. Prove process +termination, every relevant interruption boundary, cross-platform behavior and +cleanup before enabling the owner. A lockfile, successful manager exit or this +fixture is not authentication or crash-safety proof; detailed negative +evidence remains in the private research packet. + +For the observed Bun 1.4.4 fixture, classify global manifest/lock, `.bun-tag`, +cache metadata and bin links as manager-generated state, separate from signed +package files. The adapter accepts only version-specific parsed schemas and +expected link targets bound to the selected root/bin/cache and verified +payload; no unrecognized generated executable, metadata form or escaping link +is accepted. A recorded digest detects later drift but does not turn local +metadata into a publisher signature. Bun 1.4.3 and all untested layouts remain +unavailable until separately proven. Test foreign entries, custom roots, +changed bins, unrelated global packages and interrupted recovery before any +candidate executable is launched. diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md b/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md new file mode 100644 index 00000000000..79f9ec451c9 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/000_roadmap.md @@ -0,0 +1,116 @@ +# Ephemeral Rust Child Link (`native/ocx-child`) Eliminates Full-Proxy Child Footprint While Preserving Exact Link, Sync, and Offline Restore Parity + +> Reader contract: For OpenCodex maintainers and reviewers evaluating Remote Link Child architecture, security boundaries, and phased execution on `feat/ocx-child-ephemeral-link` targeting `dev`. Document type: Decision & Roadmap Specification (`000`-range, zero implementation diffs per `LEXICO-SPLIT-01`). + +Remote Link today requires every Child machine (`src/server/management/link-enrollment.ts:147-180`) to pre-install and run a full standalone Bun OpenCodex proxy (~100-217 MB runtime footprint) and recycles back into a persistent standalone daemon on disconnect (`src/client/runtime.ts:133`), which is unsuitable for disposable SSH devboxes, containers, and lightweight VMs. How can OpenCodex attach a Remote Link Child with a megabyte-scale, zero-Bun/zero-Node footprint that relays inference/SSE traffic, participates byte-for-byte in Home `ocx sync` fan-out and OCX-6 parity, and restores native `~/.codex` state 100% offline with zero leftover daemon? We introduce `native/ocx-child` (standalone Rust crate compiling to a stripped ~1-2 MB `ocx-child` binary) paired with minimal Home-side discovery and detached-preflight support in `src/link/` and `src/server/management/`, locked to TypeScript behavior via a shared golden conformance fixture (`tests/fixtures/link/ocx-child-conformance.json`). + +## Contents + +1. [Loop Specification & HOTL Bounds](#1-loop-specification--hotl-bounds) +2. [Scope](#2-scope) +3. [Architect Consultation & Design Dispositions](#3-architect-consultation--design-dispositions) +4. [Dependency-Ordered Work-Phase Map (`PHASE-SPLIT-01`)](#4-dependency-ordered-work-phase-map-phase-split-01) +5. [Cross-Boundary Field & Type Chains (`PLAN-FIELD-CHAIN-01`)](#5-cross-boundary-field--type-chains-plan-field-chain-01) +6. [Verification scope](#6-verification-scope) +7. [Grounded Verifiers (`PLAN-VERIFIER-REAL-01`)](#7-grounded-verifiers-plan-verifier-real-01) +8. [Non-Goals, Alternatives Rejected, & Consequences](#8-non-goals-alternatives-rejected--consequences) + +## 1. Loop Specification & HOTL Bounds + +| Field | Specification | +|---|---| +| Loop archetype | `satisfy-spec` (Class C4 multi-cycle HOTL PABCD with docs-first `wp0` entry per `LOOP-DOCS-FIRST-01`) | +| Trigger | Dispatched GOAT task lane `lane-ocx-child-ephemeral-link` on branch `feat/ocx-child-ephemeral-link` + user steering requiring seamless `ocx sync` fan-out, OCX-6 runtime freshness/parity, and full sibling CLI compatibility | +| Goal | Deliver `native/ocx-child`, Home-side ephemeral-child Remote Link integration, shared TS/Rust golden conformance vectors, updated `structure/` and `docs-site/` documentation, and a verified unmerged PR targeting `dev` | +| Non-goals | Merging the PR (`authority.merge = false`), force-pushing, dispatching manual `lane=all` CI, mutating `.codexclaw` subagent configs, editing `.github/` workflows, touching `src/lab/` or `core-link-boundary` protected files (`src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts`), raising file-size ratchets, or implementing Child-initiated (`ssh -L` `join`) / web-dashboard modes inside `ocx-child` | +| Verifier | Baseline & gate commands enumerated in [Section 7](#7-grounded-verifiers-plan-verifier-real-01): `bun run typecheck`, `cargo test --locked --manifest-path native/ocx-child/Cargo.toml`, `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml`, focused Bun conformance/unit/E2E suites, `bun run test:changed`, `bun run structure:check`, `cd docs-site && bun run build`, and `bun run privacy:scan` | +| Stop condition | All work-phases `wp0..wp4` and criteria `c-1..c-6` met with fresh command receipts, PR opened against `dev` and linked via `goat_pull_request_link`, exact-head PR checks green and recorded via `cxc loop pr-record`, `goat_pull_request_unwatch` called, and `update_goal({ status: "complete" })` accepted | +| Memory artifact | `devlog/_plan/261107_ocx_child_ephemeral_link/` (`000_roadmap.md` + `010..040` decade PRDs) and `.codexclaw/goalplans/loop-archetype-satisfy-spec-class-c4-hotl-multi/goalplan.json` | +| Expected terminal outcomes | `DONE`: all `c-1..c-6` verified + green unmerged PR recorded; `NOOP`: N/A (greenfield subsystem); `BLOCKED`: reproducible toolchain/CI/policy refusal reported with `file:line` and command output without weakening gates; `UNSAFE`: unmitigated credential/smuggling/restore defect; `NEEDS_HUMAN`: out-of-scope permission needed; `BUDGET_EXHAUSTED`: host limit reached | +| Escalation condition | Any requirement to edit outside `writeScope` (`native/ocx-child`, `src/link`, `src/server/management`, `src/cli`, `structure`, `docs-site`, `scripts`, `tests`, `package.json`, `devlog`), weaken a check, or merge the PR. Subagent recovery follows `DISPATCH-RETIRE-01` (inherit `google-antigravity/gemini-4-argon` per user directive) | +| HOTL resource bounds | Token/cost/wall-clock bounds: `none stated; host limits apply`. Tool/credential scope: local git/bun/cargo, `gh` PR creation/inspection on `feat/ocx-child-ephemeral-link`, GOAT PR link/watch/unwatch | + +## 2. Scope + +Security working analysis is kept outside the tracked repository. + +## 3. Architect Consultation & Design Dispositions + +- **Proposals consulted**: + - Initial read-only architect handle `01a1b183-8618-7280-bcc9-f1da84898e7d` (retired after wait timeout, completed text captured on `close_agent`) and replacement live read-only architect handle `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7` (both inherited `google-antigravity/gemini-4-argon`). +- **Main dispositions on `ARCH-01..ARCH-07`**: + - **`ARCH-01` (Amended)**: Adopt standalone `native/ocx-child` crate with `std::net` + bounded `std::thread` worker pool, surgical line/span TOML module, and minimal audited dependencies (`serde`, `serde_json`, `sha2`, `hmac`, `base64`, Unix `libc`, Windows `windows-sys`) matching `native/remote-workspace-helper` and `desktop/src-tauri`. Reject `tokio`/`hyper`/`rustls`/`toml_edit` because `ocx-child` speaks loopback HTTP/1.1 only (never TLS), keeping dependency count minimal and stripped release binary ~1-2 MB. + - **`ARCH-02` (Amended)**: Adopt strict HTTP/1.1 framing + `linkRouteAllowed` / `linkManagementRouteAllowed` + `CALLER_CREDENTIAL_HEADERS` stripping + `/healthz` + `/readyz` + `child-parity` reporter. Reject upstream socket pooling: Home's `src/server/index/link-relay-sessions.ts:105` consumes each `nonce` once (`consume(nonce)`), so each relayed request opens one `TcpStream` and runs `preflight 204 -> single data request` on that exact socket, matching `SingleConnectionAgent` (`src/client/link-relay-transport.ts:41`). + - **`ARCH-03` (Amended)**: Adopt `0600` journal + ordered locks (`$OPENCODEX_HOME/child/lifecycle.lock` -> `$CODEX_HOME/.opencodex-write.lock`) + fail-closed checks (full-`ocx` runtime/journal, foreign listener, external `model_provider`, user-owned root `openai_base_url`) + two-tier offline detach (byte-exact restore when `untouchedByExternalWriter` holds across attaches/syncs; surgical value-matched strip surviving `#1798` comment-stripping rewrites; fail-closed exit `4` on ambiguous/conflicting edits). Reject `[model_providers.opencodex]` table injection: scope strictly to Design B loopback root keys + Home `codex-settings` (`[features]`, `[agents]`, `web_search`) + `models_cache.json` refresh with websockets disabled. + - **`ARCH-04` (Amended)**: Adopt unified native (`attach`, `sync`, `detach`, `serve`, `status`, `port`) and `ocx`-compatible (`--version` -> `opencodex 2.98.0`, `link port`, `config show --json`, `connect --link --key-stdin`, `restart`, `disconnect`, `sync [--expect-link ] [--json]`, `status [--json]`, `connect status [--json]`) CLI verbs. Enforce exact `src/cli/sync-expectation.ts:55` stderr mismatch JSON `{"phase":"child_link_mismatch","reason":"..."}` with exit `5` (including `pending_operation` on lock contention) and post-sync live daemon rollover + immediate `child-parity` POST. + - **`ARCH-05` (Amended)**: Adopt ordered remote candidate fallback (all full-`ocx` paths first, then `ocx-child` in `~/.local/bin`, `~/.cache/opencodex/bin`, Windows `%LOCALAPPDATA%\opencodex\bin`, and `PATH`, keeping Windows encoded PowerShell `< 8000` chars) and detached `ocx-child` preflight in `src/server/management/link-enrollment.ts`. Reject adding new fields to persisted `LinkRecord` in `links.json` (`src/link/store.ts:16` enforces closed `exactKeys`, and `remoteOcxArgv(["disconnect"])` + `remotePinnedSyncArgv` already operate uniformly once `ocx-child` is in candidate discovery). + - **`ARCH-06` (Accepted)**: Shared golden conformance fixture `tests/fixtures/link/ocx-child-conformance.json` (`schema: "ocx.child.conformance/1"`) executed by both `bun test` and `cargo test`. + - **`ARCH-07` (Accepted)**: Place new TS helper at `src/link/ephemeral-child.ts`, keep zero edits in `src/cli/index.ts` or any `file-size-baseline.json` file, keep `core-link-boundary` and `core-lab-boundary` untouched, register all new TS tests in `scripts/test-layout/layout-clients.json` + `tests/fixtures/test-layout-expected.json`, and update `structure/remote-link.md` + `docs-site/`. +- **Same-architect reflection (`01a1b18b-a2f3-7b53-971a-9b2c9cccecf7`)**: + - Submitted concrete plan revision: `devlog/_plan/261107_ocx_child_ephemeral_link/{000_roadmap.md,010_wp1_conformance_contract.md,020_wp2_rust_ocx_child.md,030_wp3_home_link_integration.md,040_wp4_docs_gates_and_pr.md}` (recorded in Section 3.1 immediately after reflection response). + +### 3.1 Reflection Record + +- **Handle `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7` reflection verdict**: `ALIGNED`. +- **Decision-ID mapping verified**: `ARCH-01` -> `000` §3 + `020` §1.1–§1.2; `ARCH-02` -> `000` §3 + `020` §1.2.3–§1.2.4; `ARCH-03` -> `000` §3, §5 + `020` §1.2.5; `ARCH-04` -> `000` §3, §5 + `020` §1.2.6; `ARCH-05` -> `000` §3, §8 + `030` §1.1–§1.3; `ARCH-06` -> `000` §5 + `010` §1.2–§1.3 + `020` §1.3; `ARCH-07` -> `000` §3, §7 + `010` §1.1 + `030` §1.5 + `040` §1.1–§1.2. +- **Minor notes folded into `020_wp2_rust_ocx_child.md`**: + - `Gap-1` (Accepted): `ChildJournalV1` snapshots `preAttachModelsCacheExisted` / `preAttachModelsCacheBytesB64` / `latestModelsCacheSha256` so offline `detach` restores pre-attach `models_cache.json` (or unlinks an unchanged cache `ocx-child` created) and never leaves stale Home slugs in Codex's model picker. + - `Gap-2` (Accepted): `native/ocx-child/Cargo.toml` includes `Win32_Security_Authorization` on `windows-sys` and `write_private_atomic` on Windows hardens `$OPENCODEX_HOME/child` and its `0600`-equivalent secret/journal files with an owner-only protected DACL. + +## 4. Dependency-Ordered Work-Phase Map (`PHASE-SPLIT-01`) + +Phases follow strict dependency order (shared wire/golden contracts -> standalone Child binary -> Home orchestration & E2E integration -> SoT documentation & publication): + +| Work Phase | Decade PRD | Depends On | Deliverable & Independent Verification Closure | +|---|---|---|---| +| `wp0` | `000_roadmap.md` + `010..040` | — | Complete roadmap and diff-level PRDs audited and closed with zero production edits (`LOOP-DOCS-FIRST-01`) | +| `wp1` | `010_wp1_conformance_contract.md` | `wp0` | `src/link/ephemeral-child.ts` pure helpers + `tests/fixtures/link/ocx-child-conformance.json` + `tests/clients/link-ocx-child-conformance.test.ts` registered in dual test-layout maps; passes `bun test` + `bun run typecheck` | +| `wp2` | `020_wp2_rust_ocx_child.md` | `wp1` | `native/ocx-child/` crate (`Cargo.toml`, `Cargo.lock`, `src/**`, `tests/conformance.rs`, `tests/relay_and_lifecycle.rs`) passing `cargo test --locked`, `cargo clippy --locked --all-targets -- -D warnings`, and `cargo build --release --locked` (~1-2 MB stripped binary) | +| `wp3` | `030_wp3_home_link_integration.md` | `wp2` | Home candidate discovery (`src/link/ssh-argv.ts`, `src/link/remote-windows.ts`), detached `ocx-child` enrollment preflight (`src/link/remote-probe.ts`, `src/server/management/link-enrollment.ts`), `package.json` scripts/files, and focused + real-binary E2E tests (`tests/clients/link-ephemeral-child-discovery.test.ts`, `tests/server/link-enrollment-ephemeral-child.test.ts`, `tests/clients/ocx-child-e2e.test.ts`) | +| `wp4` | `040_wp4_docs_gates_and_pr.md` | `wp3` | `structure/manifest.json` + `structure/remote-link.md` + `structure/overview.md` (`INV-LINK-01` or bound invariant) + `docs-site/` updates, full verification matrix, branch push, unmerged PR targeting `dev` with Security Review Summary, `goat_pull_request_link`, green checks, and `cxc loop pr-record` | + +Single-PR delivery decision (`DEV-STACK-01`): Delivered as a single focused PR targeting `dev` because `native/ocx-child`, its golden conformance vectors, and the ~60-line Home discovery/preflight integration form one cohesive feature slice within the lane's authorized branch `feat/ocx-child-ephemeral-link`. + +## 5. Cross-Boundary Field & Type Chains (`PLAN-FIELD-CHAIN-01`) + +| Field / Schema | Creation | Serialization | Deserialization | Consumers | +|---|---|---|---|---| +| `ephemeralChild: true` on Child `config show --json` (`EphemeralChildConfigProjection`) | `native/ocx-child/src/cli.rs` (`cmd_config_show`) | `serde_json::to_string` on `ocx-child` stdout over SSH | `JSON.parse` in `src/server/management/link-enrollment.ts` -> `isDetachedEphemeralChildConfig` in `src/link/ephemeral-child.ts` | `src/server/management/link-enrollment.ts` (`apply` preflight branching between live standalone `ocx` vs clean detached `ocx-child`); full `ocx` omits field -> defaults to existing standalone branch | +| `ephemeralChild: true` on Child `GET /healthz` | `native/ocx-child/src/relay.rs` (`handle_healthz`) | `serde_json::to_vec` HTTP `200` body | `parseChildHttp` (`src/link/remote-probe.ts:30`) / `Response.json()` in `src/cli/link-apply-restart.ts:36` & `src/server/proxy-liveness.ts` | `isChildHealth(..., "client", childPort)` (`src/link/remote-probe.ts:35` ignores extra fields and verifies `service`, `port`, `pid`, `role === "client"`), `readClientRuntimeStartedAt`, E2E/diagnostic callers | +| `ChildStateV1` (`schema: "ocx.child.state/1"`) | `native/ocx-child/src/state.rs` (`write_state_atomic`) | `serde_json::to_vec_pretty` into `$OPENCODEX_HOME/child/state.json` (`0600`) | `serde_json::from_slice` (`#[serde(deny_unknown_fields)]`) in `native/ocx-child/src/state.rs` | Internal to `ocx-child` (`serve`, `sync`, `detach`/`disconnect`, `restart`, `status`, `config show`); N/A outside `ocx-child` because full `ocx` uses `config.json` and fail-closed guards forbid mixing | +| `ChildJournalV1` (`schema: "ocx.child.journal/1"`) | `native/ocx-child/src/journal.rs` (`create_or_update_journal`) | `serde_json::to_vec_pretty` into `$OPENCODEX_HOME/child/codex-journal.json` (`0600`) | `serde_json::from_slice` (`#[serde(deny_unknown_fields)]`) in `native/ocx-child/src/journal.rs` | `ocx-child` `sync` (updates `latestInjectedConfigSha256`, `ledger`, `latestCatalogSha256`) and offline `detach`/`disconnect` (Tier-1 hash restore or Tier-2 surgical value strip); also conformance harness in `native/ocx-child/tests/conformance.rs` | +| `ConformanceSuite` (`schema: "ocx.child.conformance/1"`) | Checked-in JSON fixture `tests/fixtures/link/ocx-child-conformance.json` verified against TS oracle functions | Static UTF-8 JSON file in git | `JSON.parse` in `tests/clients/link-ocx-child-conformance.test.ts` AND `serde_json::from_str` in `native/ocx-child/tests/conformance.rs` | Both `bun test` and `cargo test` conformance runners asserting identical outputs across route admission, framing/stripping, HMAC v2, OCX-6 digest, child-parity validation, and TOML/catalog round-trips | +| `ClientSyncMismatchReason` stderr JSON (`{"phase":"child_link_mismatch","reason":"..."}`) | Existing closed union in `src/client/sync-expectation.ts:17` (no new enum members added); emitted by `native/ocx-child/src/cli.rs` on `--expect-link` mismatch/busy lock | `serde_json::to_string` as final stderr line with exit code `5` | `JSON.parse(last ?? "")` in `src/cli/sync-fanout.ts:62` checking `mismatchReasons` set | Home `ocx sync [--json]` fan-out classifier (`classifySyncResult` -> `skipped("child-link-mismatch:")`) | + +## 6. Verification scope + +The work packages record implementation and conformance-test status. + +## 7. Grounded Verifiers (`PLAN-VERIFIER-REAL-01`) + +Every existing repository gate below was executed on the clean worktree before authoring this roadmap: + +| Verifier Command | Baseline Exit Today | How It Reads This Unit's Change Target | +|---|---|---| +| `bun run typecheck` (`bun check --no-script --noEmit`) | `0` (`2,318 files`) | `tsconfig.json` includes `src/**/*.ts`, `scripts/**/*.ts`, and `tests/**/*.ts`, directly typechecking `src/link/ephemeral-child.ts`, `src/server/management/link-enrollment.ts`, and all new `tests/**/*.test.ts` | +| `bun scripts/structure-ssot.ts` (`bun run structure:check`) | `0` | Reads `structure/manifest.json`, `structure/INDEX.md`, `structure/overview.md`, `structure/remote-link.md`, and `git ls-files` (`scripts/structure-ssot.ts:315-635`), validating `src/link/` & `native/ocx-child/` path references and invariant bindings | +| `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | `0` (`28 pass`) | Walks `tests/**/*.test.ts`, `scripts/test-layout/layout.json` + `explicitShards` (`scripts/test-layout/layout-clients.json`), and `tests/fixtures/test-layout-expected.json` (`tests/test-layout-tooling.test.ts:28,287`) | +| `bun test tests/lab/core-lab-boundary.test.ts tests/lab/core-link-boundary.test.ts` | `0` | Walks static imports from `src/router.ts`, `src/server/lifecycle.ts`, and `src/server/responses/core.ts` (`tests/lab/core-link-boundary.test.ts:9-48`) | +| `bun test tests/clients/link-pinned-sync-argv.test.ts tests/clients/link-ssh-argv.test.ts tests/clients/link-ssh-argv-discovery.test.ts` | `0` | Directly imports `src/link/ssh-argv.ts` and `src/link/remote-windows.ts` and executes `REMOTE_OCX_SCRIPT`, `REMOTE_PINNED_SYNC_SCRIPT`, `windowsOcxArgv`, and `windowsPinnedSyncArgv` (`POWERSHELL_ENCODED_MAX = 8000`) | +| `bun run privacy:scan` (`bun scripts/privacy-scan.ts`) | `0` (verified in `wp0` Check) | Scans tracked git files across `devlog/`, `src/`, `native/`, `tests/`, and `docs-site/` | +| `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` & `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` | Phase-created in `wp2` (`rustc 1.99.0` / `cargo 1.99.0` verified present) | Compiles and executes `native/ocx-child/src/**` and `native/ocx-child/tests/**` directly from `native/ocx-child/Cargo.toml` | +| `cd docs-site && bun install --frozen-lockfile && bun run build` | Executed in `wp4` when `docs-site/` changes | Astro/Starlight build reading `docs-site/src/content/docs/guides/remote-link.md` and `reference/cli/lifecycle.md` | + +## 8. Non-Goals, Alternatives Rejected, & Consequences + +- **Non-goals**: + - SCP/SFTP binary auto-upload from Home during `ocx link add` (`ocx-child` is discovered on the remote host like `ocx`). + - Child-initiated (`POST /api/link/join` / local `ssh -L` tunnel supervisor) or local web dashboard on `ocx-child`. + - Provider-table (`[model_providers.opencodex]`) auth-header injection on `ocx-child` (loopback Design B needs no local bearer token). +- **Alternatives rejected**: + - *Compiling `src/` with `bun build --compile` for Children*: Still ~100-217 MB and embeds the full proxy/GUI/provider runtime. + - *Async `tokio` + `hyper` + `toml_edit` Rust stack*: Adds ~60+ crates and megabytes of binary weight for loopback-only HTTP/1.1 streams and line-oriented TOML edits. + - *Adding `childKind` to `links.json`*: Breaks strict `exactKeys` compatibility in `src/link/store.ts:16` on downgrade while providing zero benefit once `remoteOcxArgv` and `remotePinnedSyncArgv` discover `ocx-child` in fallback order. +- **Consequences**: + - Ephemeral SSH hosts need only a single ~1-2 MB static-friendly Rust binary (`ocx-child`) and `curl` (POSIX) / PowerShell (Windows). + - Both `bun test` and `cargo test` must keep `tests/fixtures/link/ocx-child-conformance.json` green whenever Link routes, relay-auth v2, or OCX-6 settings digests evolve. diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/001_audit_and_status.md b/devlog/_plan/261107_ocx_child_ephemeral_link/001_audit_and_status.md new file mode 100644 index 00000000000..8e60f63a076 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/001_audit_and_status.md @@ -0,0 +1,27 @@ +# `261107_ocx_child_ephemeral_link` — Audit Disclosures & Phase Status Ledger + +> Reader contract: Maintainer and reviewer ledger tracking PABCD audit rounds, model-inheritance disclosures (`REVIEW-DECORRELATE-01`), and per-phase verification evidence (`LOOP-PESSIMIST-01`). + +## 1. Model Inheritance Disclosure (`REVIEW-DECORRELATE-01`) + +Per explicit user directive (`"너 상속 으로 해서"`), all PABCD subagents (`explorer`, `architect`, `reviewer`) inherit the coordinator's model (`google-antigravity/gemini-4-argon`) without mutating shared `.codexclaw` subagent configuration files. Cross-family decorrelation was therefore intentionally waived by user instruction and compensated by fresh zero-conclusion prompts and real command-backed verification. + +## 2. `wp0` (Docs-Only Roadmap Cycle) Audit & Closure + +- **Architect consultation**: Handle `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7` returned `ALIGNED` on `ARCH-01..ARCH-07`; `Gap-1` (`models_cache.json` snapshot/restore in `ChildJournalV1`) and `Gap-2` (Windows owner-only DACL via `Win32_Security_Authorization`) folded into `020_wp2_rust_ocx_child.md`. +- **Independent A-gate review**: Handle `01a1b197-8e50-7722-bf0d-d13bb303f767` Round 1 returned `VERDICT: GO-WITH-FIXES (blockers=2)`; all 5 findings (omitting `restartCapability` on `ocx-child` client `/healthz`, `ephemeralChild`-specific `apply()` failure recovery string + fail-closed detached `restart`, `010` header dependencies + exact `isAllowedRequestOrigin` loopback Host/Origin check, `sync --json` stderr redirection + attached `serve` start rationale, and clean `corrupt_managed_span_refused` vector) were folded into `010`, `020`, and `030`. Round 2 re-audit returned `VERDICT: PASS` (`blocking_issues: []`). +- **`LOOP-PESSIMIST-01` notes (`wp0`)**: + - *Dead hypothesis*: Upstream socket pooling across relayed requests (`ARCH-02` alternative) died against `src/server/index/link-relay-sessions.ts:105` (`consume(nonce)` is strictly single-use), confirming one-shot `SingleSocketUpstream` per relayed request. + - *Dead hypothesis*: Advertising `restartCapability: "v1"` on `ocx-child` `GET /healthz` died in Round-1 audit because `ocx-child` does not serve `POST /api/system/restart` and `isChildHealth(..., "client", port)` does not require it. + - *Refutation trigger for `wp1..wp3`*: Any divergence between TypeScript and Rust outputs on `tests/fixtures/link/ocx-child-conformance.json`, or Windows encoded PowerShell exceeding `8000` chars. + +## 3. Work-Phase Status Table + +| Work Phase | Status | Summary | +|---|---|---| +| `wp0` | Completed | Docs-first roadmap (`000_roadmap.md`, `001_audit_and_status.md`, `010..040` decade PRDs) audited and locked with zero production edits | +| `wp1` | Completed | Shared golden conformance fixture (`ocx-child-conformance.json`), `src/link/ephemeral-child.ts`, and `tests/clients/link-ocx-child-conformance.test.ts` (65 pass, 0 fail; typecheck clean) | +| `wp2` | Completed | Standalone Rust crate `native/ocx-child` (stripped release binary; conformance, relay, and process-level lifecycle suites in `native/ocx-child/tests/`; `clippy -D warnings` & `fmt --check` clean). Current test counts are in the PR #8237 Verification section | +| `wp3` | Completed | Home discovery (`ssh-argv.ts`, `remote-windows.ts`), detached enrollment preflight (`link-enrollment.ts`), `package.json` scripts/files, and focused + real-binary E2E suites (`link-ocx-child-e2e.test.ts` 811ms pass) | +| `wp4` | Completed | `structure/manifest.json` + `structure/remote-link.md` + `structure/overview.md` (`INV-LINK-CHILD-01`) + `structure/INDEX.md`, `docs-site/` build (641 pages, 93,153 links), full gate matrix, and PR targeting `dev` | +| `wp5` | In Progress | PR #8237 review rounds: write-ahead recovery journal, multiline TOML structural scanner, overwritten user `[features]` surgical restoration, shared `CODEX_HOME` ownership, per-step home identity rechecks during restoration, committed daemon generations, bounded relay shutdown, and `models_cache.json` slug ownership across sync and detach, each with shared golden or Rust regression tests | diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/010_wp1_conformance_contract.md b/devlog/_plan/261107_ocx_child_ephemeral_link/010_wp1_conformance_contract.md new file mode 100644 index 00000000000..085eb40623f --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/010_wp1_conformance_contract.md @@ -0,0 +1,124 @@ +# `wp1` — Shared Golden Conformance Vectors & Ephemeral Child Contract Helpers + +> Continuity handoff (`LOOP-CONTINUITY-01` from `wp0` D at `a5c605fafc`): `wp0` locked the docs-first roadmap with zero production edits (`VERDICT: PASS` / `GO-WITH-FIXES (blockers=0)`). Revalidated `010_wp1_conformance_contract.md` against current HEAD: `isAllowedRequestOrigin` (`src/server/auth-cors.ts:118`) is added to `Header dependencies` and `routeCases` so DNS-rebinding `Host` and cross-origin `Origin` `403` rejections are locked in the shared golden fixture alongside route admission, smuggling, HMAC v2, OCX-6 digests, child-parity, and `#1798` journal round-trips. Architect consultation: covered by `ARCH-06` & `ARCH-07` (`ALIGNED` on handle `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7`, `000_roadmap.md` §3.1). + +- **Phase ID**: `wp1` (depends on `wp0`; satisfies `c-2`) +- **Header dependencies**: + - Existing types/functions consumed: `linkRouteAllowed` (`src/link/routes.ts:15`), `linkManagementRouteAllowed` (`src/link/management-routes.ts:72`), `isAllowedRequestOrigin` (`src/server/auth-cors.ts:118`), `computeLinkRelayProof`, `computeLinkConnectionProof`, `verifyLinkRelayProof`, `verifyLinkConnectionProof` (`src/link/relay-auth.ts:45-95`), `HomeCodexSettings`, `codexSettingsDigestPayload`, `codexSettingsDigest` (`src/link/codex-settings.ts:30-182`), `parseLinkChildParityReport` (`src/link/child-parity.ts:67`), `validateHubRelayRequestHeaders` (`src/client/hub-relay.ts:49`), `classifySyncResult` (`src/cli/sync-fanout.ts:52`), `externalCodexModelProvider` & `ensureRootWebSearchDisabled` (`src/codex/inject/config-toml.ts:30,366`), and `stripJournaledOpenaiBaseUrl` (`src/codex/injected-marker.ts:106`). + - Shared types exported/validated in this phase: `EphemeralChildConfigProjection`, `isDetachedEphemeralChildConfig`, `isEphemeralChildHealth` (`src/link/ephemeral-child.ts`), and `ConformanceSuite` (`tests/fixtures/link/ocx-child-conformance.json`). +- **Scope**: + - **IN**: `src/link/ephemeral-child.ts` (NEW), `tests/fixtures/link/ocx-child-conformance.json` (NEW), `tests/clients/link-ocx-child-conformance.test.ts` (NEW — named `link-*` to satisfy `seeds.json` vs `cli` prefix rule), `scripts/test-layout/layout-clients.json` (MODIFY), `tests/fixtures/test-layout-expected.json` (MODIFY). + - **OUT**: `native/ocx-child/**` (`wp2`), `src/server/management/**` (`wp3`), `structure/**` & `docs-site/**` (`wp4`). + +## 1. Exact File Changes + +### 1.1 NEW `src/link/ephemeral-child.ts` + +Pure, zero-side-effect TypeScript contract helper module in `src/link/` (starts no timers, sockets, or processes on import; unreachable from `src/router.ts`, `src/server/lifecycle.ts`, and `src/server/responses/core.ts`): + +```ts +/** + * Contract predicates for the lightweight `ocx-child` ephemeral Remote Link Child runtime. + * + * Kept pure inside `src/link/` so enrollment and diagnostics can distinguish a clean detached + * `ocx-child` host from a full standalone `ocx` daemon without changing `LinkStore` on disk. + */ + +export interface EphemeralChildConfigProjection { + readonly port: number; + readonly runtimeRole: "standalone" | "client"; + readonly ephemeralChild: true; +} + +function isValidPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1024 && value <= 65535; +} + +/** + * True when `config show --json` came from a clean, detached `ocx-child` binary ready for + * Home-initiated enrollment (`runtimeRole: "standalone"`, `ephemeralChild: true`, valid port). + * An already-attached `ocx-child` reports `runtimeRole: "client"` and returns `false`. + */ +export function isDetachedEphemeralChildConfig(value: unknown): value is EphemeralChildConfigProjection { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return record.ephemeralChild === true + && (record.runtimeRole ?? "standalone") === "standalone" + && isValidPort(record.port); +} + +/** True when a `/healthz` payload identifies a live `ocx-child` client relay on `expectedPort`. */ +export function isEphemeralChildHealth(value: unknown, expectedPort: number): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return record.service === "opencodex" + && record.role === "client" + && record.ephemeralChild === true + && record.port === expectedPort + && typeof record.pid === "number" + && Number.isInteger(record.pid) + && record.pid > 0 + && typeof record.startedAt === "string" + && Number.isFinite(Date.parse(record.startedAt)); +} +``` + +### 1.2 NEW `tests/fixtures/link/ocx-child-conformance.json` + +Deterministic JSON document (`"schema": "ocx.child.conformance/1"`) containing 6 golden vector arrays shared verbatim between `bun test` and `cargo test`: + +1. `routeCases` (24 vectors): + - Every allowed `POST` data-plane route (`/v1/responses`, `/v1/responses/compact`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/chat/completions`, `/v1/audio/transcriptions`, `/v1/alpha/search`, `/v1/images/generations`, `/v1/images/edits`, `/v1/live`, `/v1/realtime/calls`, plus all 10 `CONTEXT_PATHS` under `/v1/alpha/{history,notes}/v2/*`) -> `expectedAction: "relay_data"`, `expectedAuthHeader: "bearer"`. + - `GET /v1/catalog`, `HEAD /v1/catalog`, `GET /v1/hub-state`, `HEAD /v1/hub-state`, `GET /v1/models`, `GET /v1/opencodex/artifacts/item-1` -> `expectedAction: "relay_data"`, `expectedAuthHeader: "bearer"`. + - `GET /v1/usage` -> `expectedAction: "relay_data"`, `expectedAuthHeader: "x-opencodex-api-key"`. + - `GET /v1/link-management/codex-settings` (empty query -> `relay_management`; `?foo=1` -> `reject_404`), `GET /v1/link-management/api/settings` (clean query -> `relay_management`; `?refresh=1` -> `reject_404`). + - `GET /healthz` -> `local_healthz`; `GET /readyz` -> `local_readyz`; `HEAD /readyz` -> `reject_404`. + - `GET /v1/responses` with `Upgrade: websocket` (loopback `Origin`) -> `reject_426_websocket`; `GET /v1/responses` with `Upgrade: websocket` + `Origin: https://evil.example` -> `reject_403_origin`; `POST /v1/responses` with `Host: evil.example:10100` -> `reject_403_origin`; `POST /v1/responses` with `Origin: https://evil.example` -> `reject_403_origin`; `GET /v1/catalog` with `Upgrade: h2c` -> `reject_404`; `GET /api/config` & `DELETE /v1/responses` -> `reject_404`. +2. `framingCases` (10 vectors): + - Valid `Content-Length: 42` with caller credentials (`Authorization: Bearer sk-user`, `x-api-key: ant-user`, `x-goog-api-key: g-user`, `api-key: az-user`, `x-opencodex-api-key: ocx-user`, `chatgpt-account-id: acc-1`, `cookie: sid=1`, `Connection: keep-alive, x-custom-secret`, `x-custom-secret: drop-me`, `x-request-trace: keep-me`) -> `allow` and exact `strippedHeaderNames` leaving only `x-request-trace`. + - Valid `Transfer-Encoding: chunked` without `Content-Length` -> `allow`. + - Simultaneous `Content-Length: 10` + `Transfer-Encoding: chunked` -> `reject_400_smuggling`. + - Duplicate `Content-Length` (`10`, `10` or `10`, `20`) -> `reject_400_smuggling`. + - Non-digit `Content-Length: +10` / `10abc` -> `reject_400_smuggling`. + - Non-chunked `Transfer-Encoding: gzip, chunked` -> `reject_400_smuggling`. + - `Upgrade: websocket` in relay header validator -> `reject_400_smuggling`. + - Header value containing `\r` or `\n` -> `reject_400_invalid`. +3. `relayAuthCases` (3 deterministic vectors using synthetic test-only tokens like `"ocx_data_0000000000000000000000000000000000000001"` so `privacy:scan` sees obvious zero-entropy test fixtures): + - Locks `tokenSha256Hex`, `expectedCallerProof`, `expectedListenerProof`, and `expectedConnectionProof` computed over raw 32-byte `Buffer.from(tokenSha256Hex, "hex")`. +4. `settingsDigestCases` (4 vectors): + - All-null defaults (`fastMode: null, multiAgentMode: null, webSearchSidecarEnabled: true, subagentDefaults: null`). + - Populated `fastMode: true, multiAgentMode: "v1", webSearchSidecarEnabled: false, subagentDefaults: { model: "gpt-5.6-sol", reasoningEffort: "high" }`. + - Populated `fastMode: false, multiAgentMode: "v2", webSearchSidecarEnabled: true, subagentDefaults: { model: "anthropic/claude-sonnet-4-5", reasoningEffort: null }`. + - `multiAgentMode: "default"` with `subagentDefaults: null`. +5. `childParityCases` (6 vectors): + - Valid pre-apply (`lastApplyAt: null, appliedCodexSettingsDigest: null`), valid post-apply, mismatched nullability (`lastApplyAt: null` with non-null digest -> invalid), extra unknown property -> invalid, out-of-range timestamp -> invalid, negative `sequence` -> invalid. +6. `journalRoundtripCases` (8 vectors): + - `clean_absent_config`: `preAttachToml: null` -> attach -> unchanged detach -> `expectedFinalToml: null` (file unlinked), `expectedDetachOutcome: "exact_restore"`. + - `preserve_comments_and_crlf`: CRLF `config.toml` with `# user comment` and `[mcp_servers.git]` -> attach -> sync with updated `fastMode` + `subagentDefaults` -> unchanged detach -> byte-exact CRLF restoration (`"exact_restore"`). + - `app_rewrite_1798_comment_strip`: attach -> Codex App strips all `# Auto-injected by opencodex...` comments and adds `model = "gpt-5.6-sol"` + `[projects."/work"]` -> detach -> strips `openai_base_url`, `experimental_realtime_ws_base_url`, `model_catalog_json`, and managed settings by exact journaled value while preserving `[projects."/work"]` (`"surgical_restore"`). + - `web_search_disabled_and_restored`: user had `web_search = "live"` -> attach with `webSearchSidecarEnabled: false` writes `web_search = "disabled"` -> `#1798` strips marker + user adds `approval_policy = "never"` -> detach restores `web_search = "live"` and keeps `approval_policy = "never"` (`"surgical_restore"`). + - `user_changed_base_url_preserved`: attach -> user edits `openai_base_url = "https://custom.gateway.example/v1"` -> detach preserves user's custom `openai_base_url` while removing remaining owned keys (`"surgical_restore"`). + - `corrupt_managed_span_refused`: attach wrote Design B root keys -> external edit corrupts `config.toml` with an unterminated quoted assignment on a managed root key (`openai_base_url = "http://127.0.0.1:10100/v1`) -> detach refuses (`expectedDetachOutcome: "refuse_conflict"`) leaving `config.toml` and `codex-journal.json` untouched. + - `conflict_tampered_catalog_refused`: attach wrote `opencodex-catalog.json` -> user hand-edits `opencodex-catalog.json` -> detach refuses (`expectedDetachOutcome: "refuse_conflict"`) rather than deleting user-modified catalog. + - `refuse_external_model_provider_on_attach`: `preAttachToml` has `model_provider = "openrouter"` + `[model_providers.openrouter]\nbase_url = "https://openrouter.ai/api/v1"` -> `expectedAttachOk: false`, `expectedDetachOutcome: "not_attached"`. + +### 1.3 NEW `tests/clients/link-ocx-child-conformance.test.ts` & Test-Layout Shards + +- Resolves fixture via `fixturePath("link", "ocx-child-conformance.json")` from `../helpers/repo-root`. +- Verifies every vector in `routeCases`, `framingCases`, `relayAuthCases`, `settingsDigestCases`, `childParityCases`, and `journalRoundtripCases` against OpenCodex's live TypeScript implementations (`linkRouteAllowed`, `linkManagementRouteAllowed`, `validateHubRelayRequestHeaders`, `computeLinkRelayProof`, `computeLinkConnectionProof`, `codexSettingsDigestPayload`, `codexSettingsDigest`, `parseLinkChildParityReport`, `externalCodexModelProvider`, `stripJournaledOpenaiBaseUrl`, `ensureRootWebSearchDisabled`), plus unit tests for `isDetachedEphemeralChildConfig` and `isEphemeralChildHealth`. +- Registers `"link-ocx-child-conformance.test.ts": "clients"` in BOTH: + - `scripts/test-layout/layout-clients.json` + - `tests/fixtures/test-layout-expected.json` + +## 2. Conditional-Path Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +1. **WebSocket `Upgrade` vs generic `Upgrade` rejection**: Driven in `routeCases` (`Upgrade: websocket` on `/v1/responses` -> `reject_426_websocket`; `Upgrade: h2c` on `/v1/catalog` -> `reject_404`) and asserted against `linkRouteAllowed` + ingress rules. +2. **Smuggling header combinations (`CL+TE`, duplicate `CL`)**: Driven in `framingCases` and asserted to return `reason: "smuggling"` from `validateHubRelayRequestHeaders`. +3. **Raw 32-byte vs ASCII-hex HMAC key**: Driven in `relayAuthCases` and asserted against `computeLinkRelayProof` / `verifyLinkRelayProof` (plus negative assertion that HMAC over ASCII hex differs). +4. **Detached vs already-attached `ocx-child` config**: Driven against `isDetachedEphemeralChildConfig` (`runtimeRole: "client"` returns `false`; `ephemeralChild` missing returns `false`; port `< 1024` returns `false`). + +## 3. Phase Verifiers + +- `bun test tests/clients/link-ocx-child-conformance.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts tests/lab/core-link-boundary.test.ts tests/lab/core-lab-boundary.test.ts` (exit `0`) +- `bun run typecheck` (exit `0`) +- `bun run privacy:scan` (exit `0`) diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/020_wp2_rust_ocx_child.md b/devlog/_plan/261107_ocx_child_ephemeral_link/020_wp2_rust_ocx_child.md new file mode 100644 index 00000000000..fd2d5da1a63 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/020_wp2_rust_ocx_child.md @@ -0,0 +1,124 @@ +# `wp2` — Standalone Lightweight Rust Child Crate (`native/ocx-child`) + +> Continuity handoff (`LOOP-CONTINUITY-01` from `wp1` D at `07f50076e5`): `wp1` landed the 74-vector `tests/fixtures/link/ocx-child-conformance.json` and `tests/clients/link-ocx-child-conformance.test.ts` (`65 pass, 0 fail`). Revalidated `020_wp2_rust_ocx_child.md` against `07f50076e5` and folded the `wp0` non-blocking Medium note: `Cargo.toml` sets crate `version = "0.1.0"` (matching `native/remote-workspace-helper/Cargo.toml:3`) while `pub const OCX_PROTOCOL_COMPAT_VERSION: &str = "2.98.0"` drives `--version` (`opencodex 2.98.0`) and peer headers, preventing `dev-version-bump.yml` drift on `package.json` bumps. Architect consultation: covered by `ARCH-01..ARCH-04` (`ALIGNED` on `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7`, `000_roadmap.md` §3.1). + +- **Phase ID**: `wp2` (depends on `wp1`; satisfies `c-3`, `c-6`) +- **Header dependencies**: + - Consumes shared fixture `ConformanceSuite` (`tests/fixtures/link/ocx-child-conformance.json`) and wire/schema contracts `EphemeralChildConfigProjection` (`src/link/ephemeral-child.ts`), `HomeCodexSettingsDocument` / `HomeCodexSettings` (`src/link/codex-settings.ts:30`), `LinkChildParityReport` (`src/link/child-parity.ts:28`), `ClientSyncMismatchReason` (`src/client/sync-expectation.ts:17`), and `FanoutDocument` (`src/cli/sync-fanout.ts:86`). + - Introduces Rust types `ChildStateV1`, `ChildJournalV1`, `ChildInjectedLedger`, `RelayServer`, `SingleSocketUpstream`, and binary `ocx-child` (`2.98.0`). +- **Scope**: + - **IN**: `native/ocx-child/Cargo.toml` (NEW), `native/ocx-child/Cargo.lock` (NEW), `native/ocx-child/src/**` (NEW), `native/ocx-child/tests/**` (NEW). + - **OUT**: `src/**`, `package.json` (`wp3`), `structure/**` & `docs-site/**` (`wp4`). + +## 1. Exact File Map & Diff-Level Specification + +### 1.1 NEW `native/ocx-child/Cargo.toml` & `Cargo.lock` + +```toml +[package] +name = "opencodex-child" +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +license = "MIT" +publish = false + +[[bin]] +name = "ocx-child" +path = "src/main.rs" + +[lib] +name = "ocx_child" +path = "src/lib.rs" + +[dependencies] +base64 = "=0.22.1" +hmac = "=0.12.1" +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +sha2 = "=0.10.9" + +[target.'cfg(unix)'.dependencies] +libc = "=0.2.191" + +[target.'cfg(windows)'.dependencies] +windows-sys = { + version = "=0.61.2", + features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Cryptography", + "Win32_Storage_FileSystem", + "Win32_System_Threading", + ], +} + +[profile.release] +codegen-units = 1 +lto = "thin" +opt-level = "s" +strip = "symbols" + +[profile.release.build-override] +strip = false +``` + +### 1.2 NEW `native/ocx-child/src/` Module Layout + +All modules set `#![deny(unsafe_op_in_unsafe_fn)]` at crate root (`lib.rs` / `main.rs`): + +1. `src/lib.rs` & `src/main.rs`: + - Exports testable library modules (`routes`, `http_wire`, `relay_auth`, `codex_settings`, `parity`, `toml_surgeon`, `journal`, `state`, `platform`, `relay`, `daemon`, `cli`). + - `pub const OCX_PROTOCOL_COMPAT_VERSION: &str = "2.98.0";` (`>= 2.93.0`, decoupled from crate `0.1.0` so automated `package.json` bumps never cause version-lock drift). +2. `src/platform/mod.rs`, `src/platform/unix.rs`, `src/platform/windows.rs`: + - `fill_random_bytes(&mut [u8])`: reads `/dev/urandom` on Unix and `BCryptGenRandom(..., BCRYPT_USE_SYSTEM_PREFERRED_RNG)` on Windows. + - `volatile_zero_bytes(&mut [u8])`: `core::ptr::write_volatile` byte loop + `core::sync::atomic::compiler_fence(Ordering::SeqCst)` (`SecretBytes` zeroes on `Drop`). + - `FileLock::acquire_blocking(path)` & `FileLock::try_acquire_nonblocking(path)`: opens lockfile with `0600` mode, acquires `libc::flock(fd, LOCK_EX)` / `LOCK_EX | LOCK_NB` on Unix and `LockFileEx(LOCKFILE_EXCLUSIVE_LOCK)` / `LOCKFILE_FAIL_IMMEDIATELY` on Windows. + - `write_private_atomic(path, bytes)`: ensures parent directory (`0700` on Unix; owner-only protected DACL via `SetNamedSecurityInfoW` / `PROTECTED_DACL_SECURITY_INFORMATION` on Windows), creates sibling temp file (`.tmp..`, `O_CREAT | O_EXCL | O_WRONLY` mode `0600` on Unix and owner-only DACL on Windows), writes all bytes, `sync_all()`, and atomically `fs::rename`s into `path`. + - `spawn_detached_serve(exe, opencodex_home, codex_home, listen_port)`: Unix `setsid` + `Stdio::null()`; Windows `CREATE_BREAKAWAY_FROM_JOB | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` breaking out of Home's `OcxJob` (`JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK` in `src/link/remote-windows.ts:69`). + - `terminate_verified_daemon(pid, expected_port)`: verifies `GET http://127.0.0.1:/healthz` reports `service == "opencodex"`, `ephemeralChild == true`, and `pid == expected_pid` before sending `SIGTERM` / attested shutdown, polling up to 5 s until exit and port release. +3. `src/routes.rs` & `src/http_wire.rs`: + - `classify_route(method, path, query, headers) -> RouteDecision`: exact port of `src/link/routes.ts:15`, `src/link/management-routes.ts:72`, and `src/client/link-ingress.ts:88-96`. + - `GET /healthz` -> `LocalHealthz` returning `{ "service": "opencodex", "version": "2.98.0", "role": "client", "ephemeralChild": true, "uptime": , "pid": , "port": , "startedAt": "" }` (`restartCapability` / `restartBudgetMs` are deliberately omitted because `ocx-child` does not serve `POST /api/system/restart`, and `isChildHealth(..., "client", port)` at `src/link/remote-probe.ts:35` checks only `port`, `service`, `pid`, and `role === "client"`). + - `GET /readyz` (without `Upgrade`) -> `LocalReadyz`. + - Loopback `Host` & `Origin` anti-rebinding gate (matching `isAllowedRequestOrigin` in `src/server/auth-cors.ts:83-125` & `src/client/link-ingress.ts:89-96`): on every relayed route and `/v1/responses` WebSocket `Upgrade`, `Host` (when parseable) must be a loopback hostname (`127.0.0.1`, `localhost`, `[::1]`) and `Origin` (when present) must be `http(s)://(:port)`; otherwise return `403 origin_rejected`. + - `/v1/responses` with `Upgrade: websocket` (after passing `Host`/`Origin` check) -> `Reject426Websocket` (`426 upgrade_required`, `"Responses WebSocket transport is disabled; use HTTP"`). + - Any other `Upgrade` header -> `Reject404`. + - Exact `POST` routes + 10 `CONTEXT_PATHS`, `GET|HEAD` `/v1/catalog` & `/v1/hub-state`, `GET /v1/models`, `GET /v1/opencodex/artifacts/*` -> `RelayData { auth: Bearer }`. + - `GET /v1/usage` -> `RelayData { auth: ApiKeyHeader }`. + - `GET /v1/link-management/codex-settings` (empty query) and `GET|HEAD /v1/link-management/api/...` (20 allowlisted paths without `refresh|reload|force|quota` query params) -> `RelayManagement` (16 MiB response cap). + - Everything else -> `Reject404`. + - `parse_and_sanitize_request_headers`: enforces `64 KiB` (`65_536` B) cap (`431`), rejects CR/LF in names/values (`400`), rejects `CL+TE` / duplicate `CL` / non-digit `CL` / non-`chunked` `TE` / `Upgrade` (`400 {"error":"link relay request headers refused"}`), strips `HOP_BY_HOP_HEADERS`, `REQUEST_OMITTED_HEADERS`, `CALLER_CREDENTIAL_HEADERS`, and `Connection`-listed tokens. + - Enforces `256 MiB` (`268_435_456` B) inbound/non-SSE outbound body cap (`413` / `502`); streams chunked and SSE (`text/event-stream`) bodies chunk-by-chunk with immediate socket `flush()` and `300 s` header / SSE idle timeout. +4. `src/relay_auth.rs`, `src/codex_settings.rs`, & `src/parity.rs`: + - Raw 32-byte `SHA-256(token)` HMAC key; exact `opencodex-link-relay-v2` caller/listener proofs and `opencodex-link-connection-v1` generation proofs with constant-time 32-byte comparison. + - `SingleSocketUpstream::open_authenticated(tunnel_port, creds, probe_only)`: opens one `TcpStream` to `127.0.0.1:` (5 s timeout), sends `GET /.well-known/opencodex/link-relay-auth?version=2&...` with peer version/features headers (plus `x-opencodex-link-probe: 1` when `probe_only`), verifies `204` + 0-byte body + `x-opencodex-link-proof` (+ optional `x-opencodex-link-connection-proof`), and returns that **exact `TcpStream`** to carry the single data request with `x-opencodex-link-session: `. + - Exact OCX-6 `codex_settings_digest` and `LinkChildParityReport` (`<= 1024` B, immediate send on start / post-sync rollover + `[2s, 5s, 15s, 30s, 60s]` backoff then `60s` period, halting on `404`). +5. `src/toml_surgeon.rs` & `src/journal.rs`: + - Ordered locks: `$OPENCODEX_HOME/child/lifecycle.lock` -> `$CODEX_HOME/.opencodex-write.lock`. + - Fail-closed attach checks: refuses if `$OPENCODEX_HOME/runtime-port.json`, `ocx.pid`, full-`ocx` `config.json`, or `$CODEX_HOME/.opencodex-journal.json` exists, if a foreign listener occupies `listen_port`, or if `config.toml` has an external `model_provider` (except `base_url`-less placeholder), unmarked user `openai_base_url` / `experimental_realtime_ws_base_url`, or non-`opencodex-catalog.json` `model_catalog_json`. + - Design B apply + `0600` `ChildJournalV1`: snapshots `preAttachConfig*`, `preAttachCatalog*`, and `preAttachModelsCacheExisted` / `preAttachModelsCacheBytesB64` / `latestModelsCacheSha256`; writes root `openai_base_url`, `experimental_realtime_ws_base_url`, `model_catalog_json`, optional `web_search = "disabled"` (saving `replacedUserWebSearchLine`), `[features]` `fast_mode` / `multi_agent_v2`, and `[agents]` subagent defaults; writes `$CODEX_HOME/opencodex-catalog.json` and refreshes `$CODEX_HOME/models_cache.json`. + - Two-tier 100% offline `detach`: Tier 1 exact byte restore when `untouchedByExternalWriter && currentSha256 == latestInjectedConfigSha256`; Tier 2 surgical value-matched strip surviving `#1798` comment stripping while preserving user edits; fails closed (`exit 4`) on corrupt managed spans or tampered `opencodex-catalog.json`; restores pre-attach `models_cache.json` (or unlinks an unchanged cache `ocx-child` created so stale Home slugs never linger); stops owned `serve` daemon and leaves zero running process. +6. `src/cli.rs`: + - Native & `ocx`-compatible verbs: `--version` (`opencodex 2.98.0`), `port` / `link port`, `config show --json`, `attach` / `connect --link --key-stdin --tunnel-port

--link-id `, `serve` / `restart` (rolls over `ocx-child serve` when attached; fails closed `exit 1` without spawning any daemon when detached so zero orphan daemon can ever start), `sync [--expect-link ] [--json]` (under `--json`, all human/diagnostic lines write strictly to `stderr` so `stdout` emits only the single `ocx.sync.fanout/1` JSON object; validates `--expect-link` under non-blocking lock with exit-5 `child_link_mismatch` / `pending_operation` stderr JSON before any network/disk I/O; intentionally starts `ocx-child serve` if absent on an attached VM or rolls it over when `daemonStartedAtMs < appliedAtMs` so `/healthz` `startedAt >= appliedAt`, and immediately POSTs `child-parity`), `detach` / `disconnect`, `status [--json]` / `connect status [--json]` / `link status [--json]`. + +### 1.3 NEW `native/ocx-child/tests/conformance.rs` & `tests/relay_and_lifecycle.rs` + +- `tests/conformance.rs`: executes all 6 vector arrays from `../../tests/fixtures/link/ocx-child-conformance.json`. +- `tests/relay_and_lifecycle.rs`: integration suite with a real mock Home `hub-link` HTTP/1.1 + SSE server verifying same-socket peer binding, single-use `nonce`, SSE streaming, smuggling/Upgrade rejection, caller credential stripping, `attach -> serve -> sync -> rollover + parity -> #1798 offline detach`, busy-lock/mismatch `--expect-link` exit `5`, and fail-closed attach/detach conflicts. + +## 2. Conditional-Path Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +1. **Same-socket binding**: Mock Home verifies preflight and data request share identical client `SocketAddr`. +2. **Spoofed Home listener proof**: Mock Home returns bad `x-opencodex-link-proof` -> `ocx-child` returns `503 link_relay_auth_failed` without sending `ocx_data_*`. +3. **`#1798` comment-stripped offline detach**: Strip all `#` comments while Home is stopped -> `ocx-child detach` strips proxy keys by value and preserves user keys. +4. **Busy-lock & mismatch `--expect-link` exit `5`**: Hold `lifecycle.lock` while invoking `sync --expect-link` -> observe exit `5` and `{"phase":"child_link_mismatch","reason":"pending_operation"}` on stderr. +5. **Fail-closed attach**: Plant `runtime-port.json` or external `model_provider` -> observe non-zero refusal with `config.toml` untouched. + +## 3. Phase Verifiers + +- `cargo fmt --manifest-path native/ocx-child/Cargo.toml --check` (exit `0`) +- `cargo clippy --locked --manifest-path native/ocx-child/Cargo.toml --all-targets -- -D warnings` (exit `0`) +- `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` (exit `0`) +- `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` (exit `0`, stripped binary size recorded) diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/030_wp3_home_link_integration.md b/devlog/_plan/261107_ocx_child_ephemeral_link/030_wp3_home_link_integration.md new file mode 100644 index 00000000000..95496d62c74 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/030_wp3_home_link_integration.md @@ -0,0 +1,125 @@ +# `wp3` — Home-Side Ephemeral Child Discovery, Enrollment, Sync Fan-Out, & E2E Integration + +> Continuity handoff (`LOOP-CONTINUITY-01` from `wp2` D at `072d491771`): `wp2` delivered `native/ocx-child` (878 KiB stripped release binary, 6/6 Rust conformance + live lifecycle suites passing). Revalidated `030_wp3_home_link_integration.md` against `072d491771`: all paths, hunks, `link-*` test-layout shard entries, and real-binary E2E steps match the compiled `ocx-child` contract. Architect consultation: covered by `ARCH-05` & `ARCH-07` (`ALIGNED` on `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7`, `000_roadmap.md` §3.1). + +- **Phase ID**: `wp3` (depends on `wp2`; satisfies `c-4`, `c-6`) +- **Header dependencies**: + - Consumes `isDetachedEphemeralChildConfig` (`src/link/ephemeral-child.ts`), `POWERSHELL_ENCODED_MAX` (`src/link/remote-windows.ts:8`), `isChildHealth`, `remoteChildHttpArgv`, `REMOTE_CURL_PREFLIGHT` (`src/link/remote-probe.ts:20-40`), `classifySyncResult` (`src/cli/sync-fanout.ts:52`), and compiled `ocx-child` binary. +- **Scope**: + - **IN**: `src/link/ssh-argv.ts` (MODIFY), `src/link/remote-windows.ts` (MODIFY), `src/server/management/link-enrollment.ts` (MODIFY), `package.json` (MODIFY), `tests/clients/link-ephemeral-child-discovery.test.ts` (NEW), `tests/server/link-enrollment-ephemeral-child.test.ts` (NEW), `tests/clients/ocx-child-e2e.test.ts` (NEW), `scripts/test-layout/layout-clients.json` (MODIFY), `tests/fixtures/test-layout-expected.json` (MODIFY). + - **OUT**: `src/cli/index.ts` (untouched — 1999/2000 cliff), `src/link/store.ts` (untouched — `LinkRecord` stays backwards-compatible), `structure/**` & `docs-site/**` (`wp4`). + +## 1. Exact File Changes & Before/After Hunks + +### 1.1 MODIFY `src/link/ssh-argv.ts` + +Extend `REMOTE_OCX_CANDIDATES` so every full-`ocx` candidate is probed first, and only when all full-`ocx` candidates miss does discovery probe `ocx-child` in `$HOME/.local/bin/ocx-child`, `$HOME/.cache/opencodex/bin/ocx-child`, and `$PATH`: + +```diff + const REMOTE_OCX_CANDIDATES = `for candidate in "$HOME/.opencodex-desktop/bin/ocx" "/Applications/OpenCodex.app/Contents/MacOS/ocx" "/usr/bin/ocx"; do + try_ocx "$candidate" "$@" + done + remaining=$PATH + while :; do + directory=\${remaining%%:*} + try_ocx "\${directory:-.}/ocx" "$@" + case "$remaining" in *:*) remaining=\${remaining#*:};; *) break;; esac +-done`; ++done ++for candidate in "$HOME/.local/bin/ocx-child" "$HOME/.cache/opencodex/bin/ocx-child"; do ++ try_ocx "$candidate" "$@" ++done ++remaining=$PATH ++while :; do ++ directory=\${remaining%%:*} ++ try_ocx "\${directory:-.}/ocx-child" "$@" ++ case "$remaining" in *:*) remaining=\${remaining#*:};; *) break;; esac ++done`; +``` + +### 1.2 MODIFY `src/link/remote-windows.ts` + +Append `ocx-child.exe` fallback candidates after all `ocx.exe` candidates in `OCX_CANDIDATES`, keeping both `windowsOcxArgv` and `windowsPinnedSyncArgv` strictly below `POWERSHELL_ENCODED_MAX = 8000`: + +```diff + const OCX_CANDIDATES = String.raw`$u=$env:USERPROFILE;$c=@("$u\.opencodex-desktop\bin\ocx.exe","$u\.local\share\goat\opencodex\current\ocx.exe","$u\.local\bin\ocx.exe") + if($env:LOCALAPPDATA){$c+="$env:LOCALAPPDATA\Programs\OpenCodex\ocx.exe"} + if($env:ProgramFiles){$c+="$env:ProgramFiles\OpenCodex\ocx.exe"} + $c+=@((Get-Command ocx.exe -Type Application -EA 0).Source) ++$c+=@("$u\.local\bin\ocx-child.exe","$u\.cache\opencodex\bin\ocx-child.exe") ++if($env:LOCALAPPDATA){$c+="$env:LOCALAPPDATA\opencodex\bin\ocx-child.exe"} ++$c+=@((Get-Command ocx-child.exe -Type Application -EA 0).Source) + `; +``` + +### 1.3 MODIFY `src/server/management/link-enrollment.ts` + +In `apply()` (`src/server/management/link-enrollment.ts:133-158`), import `isDetachedEphemeralChildConfig` from `../../link/ephemeral-child`: when `isDetachedEphemeralChildConfig(childConfig)` is `true`, require `before.code !== 0` (refusing `409 child_restart_unsupported` if any foreign/existing listener occupies `childPort`) and tailor the `remoteMayBeConnected` failure `recovery` hint to `"Run ocx-child detach (or ocx disconnect) on the Child before retrying."` so operators are never told to run `ocx restart` after `disconnect`; otherwise keep the exact existing `isChildHealth(beforeHealth, "standalone", childPort)` gate and recovery string for full `ocx`: + +```diff ++import { isDetachedEphemeralChildConfig } from "../../link/ephemeral-child"; + ... ++ let ephemeralChild = false; + let remoteMayBeConnected = false, phase = "remote_port"; + const failed = async (response: Response): Promise => { + const compensation = record ? await compensateNewLink(ctx, state, record) : null; + const outcome = compensation ?? response; + if (!remoteMayBeConnected) return outcome; + const payload = await outcome.json() as Record; +- return Response.json({ ...payload, residual: true, recovery: "Run ocx disconnect and ocx restart on the Child before retrying." }, { status: outcome.status }); ++ const recovery = ephemeralChild ++ ? "Run ocx-child detach (or ocx disconnect) on the Child before retrying." ++ : "Run ocx disconnect and ocx restart on the Child before retrying."; ++ return Response.json({ ...payload, residual: true, recovery }, { status: outcome.status }); + }; + ... + if (remotePlatform === "posix" && (await run(REMOTE_CURL_PREFLIGHT)).code !== 0) return fail("child_curl_missing", "Install curl on the Child before connecting a Remote Link.", 409); + const before = await run(remoteChildHttpArgv(childPort, "healthz", LINK_RELAY_VERIFY_MS, remotePlatform)); +- const beforeHealth = before.code === 0 ? parseChildHttp(before.stdout) : null; +- if (!isChildHealth(beforeHealth, "standalone", childPort)) return fail("child_restart_unsupported", "The Child must run a standalone OpenCodex with bound restart support; update or disconnect it before linking.", 409); ++ ephemeralChild = isDetachedEphemeralChildConfig(childConfig); ++ if (ephemeralChild) { ++ if (before.code === 0) return fail("child_restart_unsupported", "The Child's target port is already in use; disconnect or free the port before linking.", 409); ++ } else { ++ const beforeHealth = before.code === 0 ? parseChildHttp(before.stdout) : null; ++ if (!isChildHealth(beforeHealth, "standalone", childPort)) return fail("child_restart_unsupported", "The Child must run a standalone OpenCodex with bound restart support; update or disconnect it before linking.", 409); ++ } +``` + +Add `native/ocx-child` source artifacts to `"files"` and add scripts `build:ocx-child` and `test:ocx-child`: + +```diff + "native/remote-workspace-helper/Cargo.toml", + "native/remote-workspace-helper/Cargo.lock", + "native/remote-workspace-helper/src", ++ "native/ocx-child/Cargo.toml", ++ "native/ocx-child/Cargo.lock", ++ "native/ocx-child/src", + "LICENSE" + ... + "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", + "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", ++ "build:ocx-child": "cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml", ++ "test:ocx-child": "cargo test --locked --manifest-path native/ocx-child/Cargo.toml", +``` + +### 1.5 NEW Tests & Dual Test-Layout Registrations + +Register all three new test files in `scripts/test-layout/layout-clients.json` and `tests/fixtures/test-layout-expected.json`: + +1. `tests/clients/link-ephemeral-child-discovery.test.ts` (`"clients"`): POSIX & Windows candidate discovery precedence (`ocx` beats `ocx-child`; fallback selects `ocx-child`), Windows encoded PowerShell `< 8000` chars, and `OCX_PROTOCOL_COMPAT_VERSION` (`>= 2.93.0`) compatibility check via `classifyVersionProbe`. +2. `tests/server/link-enrollment-ephemeral-child.test.ts` (`"server"`): Home `probe -> confirm-host -> apply` and `DELETE /api/link/:id` across clean detached `ocx-child`, already-attached `ocx-child` (`409`), busy-port foreign listener (`409`), and full-`ocx` standalone Child (`202`). +3. `tests/clients/link-ocx-child-e2e.test.ts` (`"clients"` — `link-*` prefix per `seeds.json`): End-to-end integration driving the real compiled `ocx-child` Rust binary against a real in-process Home `hub-link` + management server (`RUSTUP_HOME` / `CARGO_HOME` preserved from `OCX_REAL_HOME` per `#7884`): enroll -> SSE inference relay with caller credential stripping -> Home `ocx sync` fan-out updating catalog & Home Codex settings -> daemon rollover (`startedAt >= appliedAt`) + immediate `child-parity` POST verified on Home `GET /api/link/status` -> Child `status --json` -> `--expect-link` mismatch exit `5` (`child-link-mismatch:other_link`) -> `#1798` comment-stripped `DELETE /api/link/:id` & offline `detach` -> byte-accurate restoration and zero running `ocx-child` process. + +## 2. Conditional-Path Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +1. **Foreign listener occupying detached `ocx-child` port**: Triggered in `link-enrollment-ephemeral-child.test.ts` -> observed `409 child_restart_unsupported` before `issueKey`. +2. **Full `ocx` precedence over `ocx-child`**: Triggered in `link-ephemeral-child-discovery.test.ts` -> observed `ocx` selected when runnable, `ocx-child` selected when `ocx` missing/broken. +3. **Post-sync runtime rollover & OCX-6 parity**: Triggered in `ocx-child-e2e.test.ts` -> observed `startedAt >= appliedAt` and Home `/api/link/status` `parity.appliedCodexSettingsDigest` matching Home's new digest. +4. **Wrong `--expect-link` fan-out skip**: Triggered in `ocx-child-e2e.test.ts` -> observed `classifySyncResult` returning `{ state: "skipped", reason: "child-link-mismatch:other_link" }`. + +## 3. Phase Verifiers + +- `bun test tests/clients/link-ephemeral-child-discovery.test.ts tests/server/link-enrollment-ephemeral-child.test.ts tests/clients/link-ocx-child-e2e.test.ts tests/server/link-enrollment.test.ts tests/clients/link-pinned-sync-argv.test.ts tests/clients/link-ssh-argv.test.ts tests/clients/link-ssh-argv-discovery.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts tests/lab/core-link-boundary.test.ts tests/lab/core-lab-boundary.test.ts` (exit `0`) +- `bun run typecheck` (exit `0`) +- `bun run privacy:scan` (exit `0`) diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/040_wp4_docs_gates_and_pr.md b/devlog/_plan/261107_ocx_child_ephemeral_link/040_wp4_docs_gates_and_pr.md new file mode 100644 index 00000000000..f52661835f4 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/040_wp4_docs_gates_and_pr.md @@ -0,0 +1,52 @@ +# `wp4` — Source-of-Truth Documentation, Full Verification Matrix, & PR Publication + +> Continuity handoff (`LOOP-CONTINUITY-01` from `wp3` D at `a49f471b98`): `wp3` completed Home discovery, detached `ocx-child` enrollment preflight, `package.json` scripts, and the compiled-binary E2E suite (`104 pass, 0 fail`). Revalidated `040_wp4_docs_gates_and_pr.md` against `a49f471b98`: all referenced repository paths are tracked in git and `INV-LINK-CHILD-01` is present in `tests/clients/link-ocx-child-conformance.test.ts:1`. Architect consultation: covered by `ARCH-07` (`ALIGNED` on `01a1b18b-a2f3-7b53-971a-9b2c9cccecf7`, `000_roadmap.md` §3.1). + +- **Phase ID**: `wp4` (depends on `wp3`; satisfies `c-5`) +- **Header dependencies**: + - References implemented paths `native/ocx-child/Cargo.toml`, `native/ocx-child/src/lib.rs`, `src/link/ephemeral-child.ts`, `src/link/ssh-argv.ts`, `src/link/remote-windows.ts`, `src/server/management/link-enrollment.ts`, `tests/fixtures/link/ocx-child-conformance.json`, `tests/clients/link-ocx-child-conformance.test.ts`, and `tests/clients/link-ocx-child-e2e.test.ts`. +- **Scope**: + - **IN**: `structure/manifest.json` (MODIFY), `structure/remote-link.md` (MODIFY), `structure/overview.md` (MODIFY), `structure/INDEX.md` (REGENERATE via `bun run structure:index`), `docs-site/src/content/docs/guides/remote-link.md` (MODIFY), `docs-site/src/content/docs/reference/cli/lifecycle.md` (MODIFY), `devlog/_plan/261107_ocx_child_ephemeral_link/STATUS.md` (NEW/UPDATE), git push + GitHub PR targeting `dev` (unmerged). + - **OUT**: Merging the PR (`authority.merge = false`), force-pushing, manual `lane=all` CI dispatch. + +## 1. Exact File Changes + +### 1.1 MODIFY `structure/manifest.json`, `structure/remote-link.md`, `structure/overview.md`, & Regenerate `structure/INDEX.md` + +- In `structure/manifest.json` (`remote-link.md` entry): add `"native/ocx-child/"` and `"src/server/management/link-enrollment.ts"` to `documents`. +- In `structure/remote-link.md` (185/600 lines): add present-tense contract section `## Ephemeral Rust Child (ocx-child)` naming `native/ocx-child/Cargo.toml`, `native/ocx-child/src/lib.rs`, `src/link/ephemeral-child.ts`, `src/server/management/link-enrollment.ts`, `tests/fixtures/link/ocx-child-conformance.json`, `tests/clients/link-ocx-child-conformance.test.ts`, and `tests/clients/link-ocx-child-e2e.test.ts` (all staged in git before running `bun run structure:check`). +- In `structure/overview.md` (319/600 lines): register bound invariant `INV-LINK-CHILD-01` with `Enforced by \`tests/clients/link-ocx-child-conformance.test.ts\`` and include `// INV-LINK-CHILD-01` in `tests/clients/link-ocx-child-conformance.test.ts`. +- Run `bun run structure:index` and verify `bun run structure:check` exits `0`. + +### 1.2 MODIFY `docs-site/src/content/docs/guides/remote-link.md` & `reference/cli/lifecycle.md` + +- Document lightweight ephemeral Child hosts (`ocx-child`), candidate discovery fallback order, clean detached enrollment, `ocx sync` fan-out parity, and 100% offline `ocx-child detach` / `disconnect` with zero background daemon. +- Validate `cd docs-site && bun install --frozen-lockfile && bun run build` exits `0`. + +### 1.3 Full Verification Matrix, Push, & PR Publication + +1. Run fresh verification suite (serializing heavy Bun runs with `/usr/bin/lockf -k /tmp/ocx-heavy-test.lock`): + - `cargo fmt --manifest-path native/ocx-child/Cargo.toml --check` + - `cargo clippy --locked --manifest-path native/ocx-child/Cargo.toml --all-targets -- -D warnings` + - `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` + - `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` (record exact byte size) + - `bun run typecheck` + - `bun run structure:check` + - `bun run skill:surface:check` + - `bun run privacy:scan` + - Focused Bun suites + `bun run test:changed` + - `cd docs-site && bun install --frozen-lockfile && bun run build` +2. Push `feat/ocx-child-ephemeral-link` to `origin`. +3. Write PR body to a `mktemp` file filling every section of `.github/PULL_REQUEST_TEMPLATE.md` (`## Summary`, `## Security Review Summary`, `## Verification`, `## Checklist`) and open PR targeting `dev` via `gh pr create --base dev --head feat/ocx-child-ephemeral-link --title ... --body-file ...`. +4. Link PR immediately via `goat_call { path: "pull_request.link", args: { url } }`. +5. Inspect exact-head checks; if pending/running, arm `goat_call { path: "pull_request.watch", args: { url } }` and yield cleanly without sleep-polling; once green, run `cxc loop pr-record --session 01a1b16f-bd9a-7a92-b78e-0ed532a2a346 --pr `, call `goat_call { path: "pull_request.unwatch", args: { url } }`, and leave PR unmerged. + +## 2. Conditional-Path Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +1. **`structure:check` git-index verification**: Stage all new files before `bun run structure:check` and verify `INV-LINK-CHILD-01` binding passes. +2. **Privacy scan on push range**: Run `bun run privacy:scan` across all committed files and verify zero findings. + +## 3. Phase Verifiers + +- All 10 commands in Section 1.3 exit `0`. +- Exact-head GitHub PR checks succeed and `cxc loop pr-record` succeeds. diff --git a/devlog/_plan/261107_ocx_child_ephemeral_link/050_wp5_pr_review_fixes.md b/devlog/_plan/261107_ocx_child_ephemeral_link/050_wp5_pr_review_fixes.md new file mode 100644 index 00000000000..e9e695064f1 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_ephemeral_link/050_wp5_pr_review_fixes.md @@ -0,0 +1,38 @@ +# `wp5` — PR #8237 Codex Review Fixes (Write-Ahead Journal, Multiline TOML Tracking, & Feature Restoration) + +> Continuity handoff (`LOOP-CONTINUITY-01` from `wp4` D at `fec8761583`): PR #8237 opened against `dev` with 13 CI checks green (`gates`, `structure gate`, `docs site build`, `docker smoke`, `npm-global`, `enforce-target`, `hygiene`, etc.) and received 3 valid automated Codex review findings on `native/ocx-child/` (`PRRT_kwDOS-0Gi860orZz`, `PRRT_kwDOS-0Gi860orZ4`, `PRRT_kwDOS-0Gi860orZ7`). Architect consultation: preserves `ARCH-03` & `ARCH-06` (`000_roadmap.md` §3). + +- **Phase ID**: `wp5` (depends on `wp4`; satisfies `c-5`) +- **Header dependencies**: + - Updates `ChildJournalV1` & `apply_attach_or_sync` & `offline_detach` (`native/ocx-child/src/journal.rs`), `ChildInjectedLedger` & `mark_structural_lines` & `upsert_table_bool` & `strip_features_ledger` (`native/ocx-child/src/toml_surgeon.rs`), and adds shared conformance vectors in `tests/fixtures/link/ocx-child-conformance.json` + `tests/clients/link-ocx-child-conformance.test.ts` + `native/ocx-child/tests/conformance.rs`. +- **Scope**: + - **IN**: `native/ocx-child/src/journal.rs` (MODIFY), `native/ocx-child/src/toml_surgeon.rs` (MODIFY), `native/ocx-child/tests/conformance.rs` (MODIFY), `tests/fixtures/link/ocx-child-conformance.json` (MODIFY), `tests/clients/link-ocx-child-conformance.test.ts` (MODIFY), `devlog/_plan/261107_ocx_child_ephemeral_link/001_audit_and_status.md` (MODIFY). + - **OUT**: Merging PR #8237. + +## 1. Exact Fixes + +1. **Finding #1 (`journal.rs:296`, P1 — Write-ahead recovery journal before overwriting Codex files)**: + - Split `prepare_codex_models_cache(cache_path, catalog_models) -> Result<(Option>, String), String>` so the target `models_cache.json` bytes and SHA-256 digest are computed *before* mutating any file in `$CODEX_HOME`. + - Call `write_journal(&paths.journal_file, &journal)?` **first** (before writing `catalog_path`, `catalog_backup_file`, `cache_path`, and `config_path`). + - Preserve each file's last known-owned on-disk SHA-256 (`previous_injected_config_sha256`, `previous_catalog_sha256`, `previous_models_cache_sha256`) across consecutive interrupted syncs without adopting external edits as owned, and treat `pre_attach_config_sha256` / `previous_*_sha256` under `untouched_by_external_writer` as `ExactRestore` during `offline_detach` (removing Child-created `models_cache.json` even if a sync interrupted before the next cache write). +2. **Finding #2 (`toml_surgeon.rs:199`, P1 — Multiline TOML string/collection boundary tracking)**: + - Port `markStructuralLines` from `src/codex/toml-source-lines.ts:158-223` into `mark_structural_lines(lines: &[&str]) -> Vec` tracking `"""..."""` (with backslash escapes and 3–5 closing quotes), `'''...'''` (3–5 closing quotes), `[...]` depth, and `{...}` depth. + - Gate every structural scan (`root_end_index`, `detect_unowned_root_routing_conflict`, `external_codex_model_provider`, `insert_root_lines`, `strip_owned_root_urls`, `strip_owned_web_search`, `ensure_root_web_search`, `set_root_catalog_path`, `strip_opencodex_catalog_path`, `upsert_table_bool`, `apply_subagent_defaults`, `strip_subagent_defaults`, `strip_features_ledger`, `surgical_detach_config`, and blank-line collapsing) on `structural[i] == true` so `[Important]` or `openai_base_url = ...` inside multiline `developer_instructions` is untouched data. +3. **Finding #3 (`toml_surgeon.rs:709`, P2 — Restore overwritten user `[features]` values on surgical detach)**: + - Add `#[serde(default)] pub prior_user_fast_mode_line: Option` and `#[serde(default)] pub prior_user_multi_agent_v2_line: Option` to `ChildInjectedLedger`. + - When `upsert_table_bool` overwrites an existing `fast_mode` or `multi_agent_v2` line in `[features]`, return `Option` of the overwritten line; on first write (`prior_ledger` had `wrote_fast_mode == None`), store that line in `prior_user_fast_mode_line` (preserving it across subsequent `sync` calls). + - In `strip_features_ledger`, when the current line in `[features]` still matches `wrote_fast_mode` / `wrote_multi_agent_v2`, replace it with `prior_user_*_line` if `Some(...)` (and drop it only when `None`), preserving any post-attach user modification. + +## 2. Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +- Add 2 new shared golden vectors to `tests/fixtures/link/ocx-child-conformance.json` (verified in both `bun test` and `cargo test`): + 1. `multiline_developer_instructions_bracket_line`: `developer_instructions = """\n[Important]\nKeep literal\n"""\n` -> attach inserts root keys *after* the closing `"""` (not inside the string!), and surgical detach after `#1798` comment strip restores cleanly. + 2. `restore_overwritten_user_features_on_surgical_detach`: pre-attach `[features]\nfast_mode = false\nmulti_agent_v2 = true\n` -> attach sets `fastMode: true, multiAgentMode: "v1"` -> user appends `[extra]\nk = 1\n` -> surgical detach restores `fast_mode = false` and `multi_agent_v2 = true` while keeping `[extra]`. +- Add `multiline_features_agents_and_blank_lines_surgical` to shared conformance vectors (`[features]`, `fast_mode = true`, repeated blank lines, and `[agents]` inside multiline string with `fastMode` and `subagentDefaults` active). +- Add deterministic `JournalWriteFault` (`AfterJournalWrite` and `AfterCatalogWrite`) consecutive-failed-sync tests in `native/ocx-child/tests/conformance.rs` verifying `ExactRestore` of `config.toml` and `pre_attach_catalog` plus removal of previous injected `models_cache.json`. + +## 3. Verifiers + +- `cargo fmt --manifest-path native/ocx-child/Cargo.toml --check` & `cargo clippy --locked --manifest-path native/ocx-child/Cargo.toml --all-targets -- -D warnings` +- `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` & `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` +- `bun test tests/clients/link-ocx-child-conformance.test.ts tests/clients/link-ocx-child-e2e.test.ts` & `bun run typecheck` & `bun run privacy:scan` diff --git a/devlog/_plan/261107_ocx_child_plant_ux/000_roadmap.md b/devlog/_plan/261107_ocx_child_plant_ux/000_roadmap.md new file mode 100644 index 00000000000..b541177ad66 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/000_roadmap.md @@ -0,0 +1,136 @@ +# Zero-Preinstall SSH Planting, Two-Tier `--purge` Detach, and `#remote` UX Complete Lightweight Ephemeral Child Onboarding + +> Reader contract: For OpenCodex maintainers and reviewers evaluating stacked PR delivery on `feat/ocx-child-plant-ux` (based on `feat/ocx-child-ephemeral-link`, #8237). Document type: Decision & Roadmap Specification (`000`-range, zero implementation diffs per `LEXICO-SPLIT-01`). + +PR #8237 introduced `native/ocx-child` (~0.9 MB standalone Rust binary) and Home discovery/enrollment support, yet operators connecting a bare SSH devbox, container, or VM still hit `502 remote_ocx_missing` unless they manually copy `ocx-child` onto the Child first, have no one-click or `--purge` option to remove the planted binary on disconnect, receive generic conflict errors when port `10100` or `~/.codex/config.toml` is already owned externally, and see no mode or settings-parity badges in `#remote`. How can OpenCodex deliver zero-preinstall SSH Child onboarding, clean two-tier disconnect (`fast detach` vs `--purge`), actionable conflict diagnostics, and full CLI + 11-locale `#remote` UX without touching release workflows or file-size ratchets? We extend Home host inspection to classify `runtimeKind` (`full` | `ephemeral` | `missing`) and `targetTriple`, verify local `ocx-child` artifacts by SHA-256 (`src/link/child-binary-source.ts`), stream atomic SSH `stdin` plants into the Child's user cache bin directory when authorized (`plant: true`), add safe managed-path `--purge` self-removal and structured conflict markers to `native/ocx-child`, persist `childKind` on `LinkRecord`, and redesign `#remote` + `ocx link add|revoke` across all 11 locales. + +## Contents + +1. [Loop Specification & HOTL Bounds](#1-loop-specification--hotl-bounds) +2. [Threat Model & Security Controls (`SEC-THREAT-01`)](#2-threat-model--security-controls-sec-threat-01) +3. [Architect Consultation & Design Dispositions](#3-architect-consultation--design-dispositions) +4. [Dependency-Ordered Work-Phase Map (`PHASE-SPLIT-01`)](#4-dependency-ordered-work-phase-map-phase-split-01) +5. [Cross-Boundary Field & Type Chains (`PLAN-FIELD-CHAIN-01`)](#5-cross-boundary-field--type-chains-plan-field-chain-01) +6. [Early-Warning & Boundary Guard Disclosure (`PLAN-BYPASS-NAMED-01`)](#6-early-warning--boundary-guard-disclosure-plan-bypass-named-01) +7. [Grounded Verifiers (`PLAN-VERIFIER-REAL-01`)](#7-grounded-verifiers-plan-verifier-real-01) +8. [Non-Goals, Alternatives Rejected, & Consequences](#8-non-goals-alternatives-rejected--consequences) + +## 1. Loop Specification & HOTL Bounds + +| Field | Specification | +|---|---| +| Loop archetype | `satisfy-spec` (Class C4 multi-cycle HOTL PABCD with docs-first `wp0` entry per `LOOP-DOCS-FIRST-01`) | +| Trigger | Dispatched GOAT task lane `lane-ocx-child-plant-ux` on branch `feat/ocx-child-plant-ux` stacked on open PR #8237 (`feat/ocx-child-ephemeral-link` at `c4d4d5d6311905bea62f065d7d04b9de7cdb5a86`) | +| Goal | Deliver zero-preinstall SSH binary planting, `--purge` managed-path removal, actionable `child_port_busy` / `child_external_provider_conflict` diagnostics, `LinkRecord.childKind` persistence/projection, CLI `--plant` / `--purge` flags, `#remote` GUI redesign across all 11 locales, `pr-assets` UI screenshots, and an unmerged stacked PR | +| Non-goals | Merging the PR (`authority.merge = false`), editing `.github/workflows/` or `scripts/release.ts`, touching `src/lab/` or `core-lab-boundary` protected files (`src/router.ts`, `src/server/lifecycle.ts`, `src/server/responses/core.ts`), raising file-size ratchets, committing PNGs onto `feat/ocx-child-plant-ux`, dispatching `lane=all` CI, mutating shared `.codexclaw` subagent configs, or calling `web_search` | +| Verifier | Grounded commands in [Section 7](#7-grounded-verifiers-plan-verifier-real-01): `cargo test` + `cargo build --release --locked`, `bun run typecheck`, `bun run test:changed`, `cd gui && bun run lint:i18n && bun run lint && bun test tests && bun run build`, `bun run build:gui`, `bun run structure:check`, `cd docs-site && bun run build`, `bun run privacy:scan`, and `gh pr checks` | +| Stop condition | All work-phases `wp0..wp4` and criteria `c-1..c-5` met with fresh receipts, UI screenshots pushed only to `origin/pr-assets`, stacked PR opened with `DEV-STACK-03` table, exact-head PR checks green and recorded via `cxc loop pr-record`, `goat_pull_request_unwatch` called, and `update_goal({ status: "complete" })` accepted | +| Memory artifact | `devlog/_plan/261107_ocx_child_plant_ux/` (`000_roadmap.md` + `010..040` PRDs) and `.codexclaw/goalplans/loop-archetype-satisfy-spec-class-c4-hotl-multi/goalplan.json` | +| Expected terminal outcomes | `DONE`: all `c-1..c-5` verified + green unmerged stacked PR recorded; `NOOP`: N/A; `BLOCKED`: reproducible CI/policy/toolchain refusal reported with `file:line` and command output without weakening checks; `UNSAFE`: unmitigated binary integrity, symlink, or path-deletion hazard; `NEEDS_HUMAN`: out-of-scope authority required; `BUDGET_EXHAUSTED`: host limit reached | +| Escalation condition | Any requirement to edit outside `writeScope` (`native/ocx-child`, `src/link`, `src/server/management`, `src/cli`, `gui`, `structure`, `docs-site`, `scripts`, `tests`, `devlog`), weaken a check, commit images to the PR branch, or merge. Subagents inherit `google-antigravity/gemini-4-argon` per user directive | +| HOTL resource bounds | Token/cost/wall-clock bounds: `none stated; host limits apply`. Tool/credential scope: local git/bun/cargo, headless browser for local `#remote` screenshot capture, `origin/pr-assets` upload, `gh pr create` / `gh pr view` / `gh pr checks`, GOAT PR link/watch/unwatch | + +## 2. Threat Model & Security Controls (`SEC-THREAT-01`) + +1. **Assets**: + - Remote Child host filesystem integrity (`$HOME/.cache/opencodex/bin/ocx-child`, `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe`, `$OPENCODEX_HOME/child/*`, `~/.codex/*`). + - Integrity and authenticity of the `ocx-child` binary streamed from Home to Child over SSH `stdin`. + - Operator consent before mutating a bare remote host (`plant: true` / `--plant` / explicit confirmation). + - Existing SSH host-key pinning (`StrictHostKeyChecking=yes`, private `known_hosts`, `BatchMode=yes`, `ClearAllForwardings=yes`) and Windows `OcxJob` process containment. +2. **Entrypoints & Trust Boundaries**: + - Home binary artifact resolution (`src/link/child-binary-source.ts`) reading `OPENCODEX_CHILD_BIN_DIR`, sidecar directories, local `target/release`, or `$OPENCODEX_HOME/cache/ocx-child//`. + - Home `POST /api/link/probe`, `POST /api/link/confirm-host`, `POST /api/link/apply`, and `DELETE /api/link/:id` (`src/server/management/link-enrollment.ts`, `link-routes.ts`). + - SSH `stdin` binary write & execution on POSIX (`sh -c`) and Windows (`powershell.exe -EncodedCommand` under `OcxJob`). + - `ocx-child detach --purge` / `disconnect --purge` self-removal (`native/ocx-child/src/cli.rs`, `platform.rs`). +3. **Attacker Capabilities & Hazard Scenarios**: + - Corrupted, truncated, symlinked, or unverified `ocx-child` binary in cache/override directory streamed onto a Child. + - Pre-existing symlink at `$HOME/.cache/opencodex/bin` or `$HOME/.cache/opencodex/bin/ocx-child` (or Windows ReparsePoint) tricking plant or `--purge` into overwriting or deleting an unintended file. + - `ocx-child detach --purge` invoked on a system/package-manager/user path (`/usr/local/bin/ocx-child`, `~/.local/bin/ocx-child`, Cargo target dir) deleting a binary Home did not plant. + - Non-interactive CLI automation running `ocx link add --expect-fingerprint ` against a bare host without intending to install software on it. +4. **Controls**: + - **SHA-256 artifact verification & regular-file checks**: `child-binary-source.ts` rejects symlinks (`lstatSync`), group/world-writable binaries on POSIX, empty/oversized (> 32 MiB) files, and requires exact `SHA256SUMS` verification for cached/distributed artifacts (computing SHA-256 over the exact read `Buffer` streamed to `stdin` to eliminate TOCTOU between hash and stream). + - **Explicit `plant` authorization**: `POST /api/link/apply` never plants unless `body.plant === true`; non-TTY `ocx link add` refuses bare hosts unless `--plant` is passed alongside `--expect-fingerprint`; GUI shows an explicit Ready-to-Plant card with architecture and byte size. + - **Symlink-refusing atomic plant & Windows `OcxJob` containment**: POSIX plant runs under `umask 077`, refuses symlinked parent/target (`[ ! -L ... ]`), writes to `.ocx-child.tmp.$$.`, verifies exact byte count and `chmod 0700`, atomically `mv -f`s into `$HOME/.cache/opencodex/bin/ocx-child`, and verifies `--version` exit `0`. Windows plant runs inside packed PowerShell + `OcxJob` (`<= 8000` UTF-16LE base64 chars), rejects `ReparsePoint` attributes on parent/target, verifies exact byte length and SHA-256, moves atomically into `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe`, and verifies `[OcxJob]::Ok`. + - **Post-plant rollback**: If `apply` fails after planting a fresh binary in that enrollment attempt (`plantedFreshBinary === true`), Home runs bounded cleanup (`ocx-child detach --purge` / managed-path removal) before returning the error so failed enrollments leave zero orphan binaries or daemons. + - **Managed-path-only `--purge`**: `native/ocx-child` inspects `current_exe` without following a final symlink (`symlink_metadata`), verifies the parent directory matches ONLY the managed plant location (`*/.cache/opencodex/bin/ocx-child` on POSIX; `*\opencodex\bin\ocx-child.exe` or `*/.cache/opencodex/bin/ocx-child.exe` on Windows), refuses symlinks/reparse points and foreign paths (treating non-managed paths as a safe no-op binary keep with a notice), removes `$OPENCODEX_HOME/child` only via `fs::remove_dir` (which fails safely if non-empty), and on Windows renames `ocx-child.exe` aside in the same directory before spawning a short detached `cmd.exe /d /c` deleter. + +## 3. Architect Consultation & Design Dispositions + +- **Proposals consulted**: + - Initial read-only architect handle `01a1b2ea-432d-7001-8efc-89edb28881db` (retired after 145s timeout with zero workspace mutations per `DISPATCH-RETIRE-01`) and live read-only architect handle `01a1b2ed-664f-7180-99b1-fd1091864296` (both inherited `google-antigravity/gemini-4-argon`). +- **Main dispositions on `ARCH-01..ARCH-06`**: + - **`ARCH-01` (Amended)**: Adopt `allowMissing?: boolean` on `POST /api/link/probe` (when `includeVersion: true`) and `POST /api/link/confirm-host`, returning `runtimeKind: "full" | "ephemeral" | "missing"`, `targetTriple: "darwin-arm64" | "darwin-x64" | "linux-x64" | "linux-arm64" | "windows-x64" | null`, `ocxVersion: string | null`, `plantable: boolean`, `binarySizeBytes?: number`, and `plantUnavailableReason?: string`. **Amendment**: When `allowMissing !== true`, `confirm-host` preserves the exact legacy `remoteOcxArgv(["--version"], remotePlatform)` call and `{ alias, fingerprint, ocxVersion }` response shape so `tests/server/link-enrollment.test.ts:90` and Find Home (`role === "child"`) remain 100% unchanged; when `allowMissing === true`, a single framed inspection command (`src/link/remote-plant.ts`) reports OS/arch, selected candidate kind (`full` vs `ephemeral` vs `missing`), and `--version` output in one SSH round-trip. + - **`ARCH-02` (Accepted)**: Implement `src/link/child-binary-source.ts` resolving `(1)` `OPENCODEX_CHILD_BIN_DIR`, `(2)` sidecar directories (`native-bin//` and beside `process.execPath`), `(3)` local `native/ocx-child/target/release/ocx-child(.exe)` when `targetTriple === hostTriple`, and `(4)` `$OPENCODEX_HOME/cache/ocx-child//` verified against `SHA256SUMS`. Read bytes once into memory and return `{ bytes, sizeBytes, sha256, targetTriple, sourceKind }`. + - **`ARCH-03` (Amended)**: Implement POSIX and Windows SSH `stdin` atomic plant and post-plant failure rollback in `POST /api/link/apply` (`plant?: boolean`). **Amendment**: Reject reordering `REMOTE_OCX_CANDIDATES` / `OCX_CANDIDATES` ahead of full `ocx` when `child/state.json` is absent — `structure/remote-link.md:187` and `tests/clients/link-ephemeral-child-discovery.test.ts` lock the invariant that full `ocx` wins when detached while `child/state.json` pins `ocx-child` once attached. On a bare host no full `ocx` exists, so `$HOME/.cache/opencodex/bin/ocx-child` / `%LOCALAPPDATA%\opencodex\bin\ocx-child.exe` is already discovered first and then pinned by `child/state.json`. + - **`ARCH-04` (Accepted)**: Implement `--purge` on `ocx-child detach` and `ocx-child disconnect` (`cli.rs` + `platform.rs`) with strict managed-path and non-symlink verification, empty `$OPENCODEX_HOME/child` directory removal, and Windows rename-aside + detached `cmd.exe /d /c` self-delete. Add deterministic conflict markers (`ocx-child-conflict:child_port_busy`, `ocx-child-conflict:child_external_provider_conflict`) and map both Home preflight and `connect` failures to `409 child_port_busy` and `409 child_external_provider_conflict` with zero Child file mutations. + - **`ARCH-05` (Amended)**: Persist optional `childKind?: "full" | "ephemeral"` on `LinkRecord` (`src/link/store.ts`), project `childKind` on `LinkStatusDto["links"][number]` (`src/link/status-projection.ts`, omitting when undefined on legacy records so legacy exact-object fixtures stay untouched while GUI defaults legacy hub-initiated rows to `"full"`), expose `homeCodexSettingsDigest` on `GET /api/link/status` for `gui-session` callers (`src/server/management/link-routes.ts:300`), and extend `DELETE /api/link/:id` body to `{ force?: boolean, purge?: boolean }` (rejecting `force: true` combined with `purge: true` as `400 invalid_body`, and running `remoteOcxArgv(["disconnect", "--purge"], platform)` when `purge === true` on `record.childKind === "ephemeral"`). + - **`ARCH-06` (Accepted)**: Extend `ocx link add [--plant]` and `ocx link revoke [--purge]`, extract modular GUI components (`gui/src/pages/RemoteLinkSheet.tsx` and `gui/src/pages/RemoteLinkDialogs.tsx`) so `RemoteLink.tsx` shrinks cleanly, style via `gui/src/styles-remote-link.css` (leaving baseline-capped `gui/src/styles.css` untouched), and update all 11 locale catalogs (`en`, `ko`, `ja`, `zh`, `zh-TW`, `de`, `fr`, `pt`, `ru`, `tr`, `vi`) in small 2–3 file batches. + +### 3.1 Same-Architect Reflection Record + +- **Submitted concrete plan revision**: `devlog/_plan/261107_ocx_child_plant_ux/{000_roadmap.md,010_wp1_rust_purge_and_conflicts.md,020_wp2_home_plant_store_and_api.md,030_wp3_cli_and_gui_remote_ux.md,040_wp4_docs_screenshots_and_pr.md}`. +- **Reflection handle `01a1b2ed-664f-7180-99b1-fd1091864296` verdict**: `ALIGNED` (zero material gaps). +- **Decision-ID mapping verified**: `ARCH-01` -> `000` §3, §5 + `010` §2.3 + `020` §2.1–§2.2; `ARCH-02` -> `000` §2–§3 + `010` §2.3; `ARCH-03` -> `000` §2–§3 + `010` §2.3 + `020` §2.2; `ARCH-04` -> `000` §3, §5 + `010` §2.1–§2.2 + `020` §2.2; `ARCH-05` -> `000` §3, §5 + `010` §2.3 + `020` §2.3; `ARCH-06` -> `000` §3 + `030` §1–§3 + `040` §1–§2. + +## 4. Dependency-Ordered Work-Phase Map (`PHASE-SPLIT-01`) + +| Work Phase | Decade Doc | Architectural Layer | Depends On | Key Deliverables & Verifiable Close | +|---|---|---|---|---| +| `wp0` | `000_roadmap.md`, `010..040` | Specification & Audit | — | Diff-level PRDs, architect reflection, independent A-gate review, verifier grounding; closes `D -> IDLE` with zero production edits | +| `wp1` | `010_wp1_rust_purge_and_conflicts.md` | Rust `ocx-child` & `src/link/` Foundations | `wp0` | `native/ocx-child` `--purge` + conflict markers; `LinkRecord.childKind`; `child-binary-source.ts`; `remote-plant.ts` + `remote-windows.ts` inspect/plant argv builders; Cargo + Bun unit tests green | +| `wp2` | `020_wp2_home_plant_store_and_api.md` | Home Management API & Status Projection | `wp1` | `probe` / `confirm-host` (`allowMissing`), `apply` (`plant` + rollback + `childKind` + `409` conflict codes), `status` (`childKind` + `homeCodexSettingsDigest`), `DELETE /api/link/:id` (`purge`); server & E2E plant/purge tests green | +| `wp3` | `030_wp3_cli_and_gui_remote_ux.md` | CLI & `#remote` Dashboard UX + 11 Locales | `wp2` | `ocx link add --plant`, `ocx link revoke --purge`, `RemoteLink.tsx` + sibling components, Ready-to-Plant card, mode/parity badges, conflict notices, Disconnect `--purge` modal, 11 locales; CLI + GUI `lint:i18n`, `lint`, `test`, `build` green | +| `wp4` | `040_wp4_docs_screenshots_and_pr.md` | SoT Docs, `pr-assets` Screenshots & Stacked PR | `wp3` | `structure/remote-link.md` + `docs-site/` sync, full gates, real `#remote` PNGs pushed ONLY to `origin/pr-assets`, stacked PR opened against `feat/ocx-child-ephemeral-link` (#8237), CI/review watch to green, `cxc loop pr-record` | + +## 5. Cross-Boundary Field & Type Chains (`PLAN-FIELD-CHAIN-01`) + +1. **`LinkChildKind` (`"full" | "ephemeral"`)**: + - **Creation**: `src/server/management/link-enrollment.ts` (`apply` sets `childKind: ephemeralChild ? "ephemeral" : "full"` on hub-initiated `LinkRecord`). Client-initiated `/api/link/issue` sets `childKind: "full"` (legacy records omit). + - **Serialization**: `src/link/store.ts` (`writeLinkStore` writes `links.json` with `"childKind"` added to `RECORD_KEYS`). + - **Deserialization**: `src/link/store.ts` (`parseRecord` validates `raw.childKind === undefined || raw.childKind === "full" || raw.childKind === "ephemeral"`). + - **Consumers**: `src/link/status-projection.ts` (`projectLinkStatus` copies optional `childKind` onto `LinkStatusDto["links"][number]`); `src/server/management/link-routes.ts` (`remove` checks `purge === true && record.childKind === "ephemeral"`); `src/cli/link.ts` (`validateStatus` accepts optional `"childKind"`, `runRevoke` uses status/store `childKind` to print `~/.cache/opencodex/bin/ocx-child detach` recovery on `--force`); `gui/src/remote-link-api.ts` (`parseRemoteLinkStatus` parses `childKind`); `gui/src/pages/RemoteLink.tsx` & `RemoteLinkDialogs.tsx` (Connected row badge, Disconnect `--purge` checkbox visibility, Force-remove command chip). +2. **`RemoteRuntimeKind` (`"full" | "ephemeral" | "missing"`) & `ChildTargetTriple` (`"darwin-arm64" | "darwin-x64" | "linux-x64" | "linux-arm64" | "windows-x64"`)**: + - **Creation**: `src/link/remote-plant.ts` (`inspectRemoteChildRuntime` parses framed SSH stdout + `resolveChildBinarySource`). + - **Serialization**: `src/server/management/link-enrollment.ts` (`probe` and `confirmHost` JSON responses when `allowMissing === true`, plus `ConfirmedLinkHost` in-memory state). + - **Deserialization**: `src/cli/link-enrollment.ts` (`offered` & confirm validator) and `gui/src/remote-link-api.ts` (`parseConfirmation`). + - **Consumers**: `src/server/management/link-enrollment.ts` (`apply` requires `plant === true` when `confirmed.runtimeKind === "missing"`, streams `targetTriple` binary); `src/cli/link-enrollment.ts` (TTY plant prompt vs non-TTY `--plant` gate); `gui/src/pages/RemoteLinkSheet.tsx` (Ready-to-Plant card vs Cached Ephemeral card vs Full OpenCodex card). +3. **Conflict Error Codes (`"child_port_busy"` | `"child_external_provider_conflict"` | `"remote_plant_failed"`)**: + - **Creation**: `native/ocx-child/src/daemon.rs` (`ensure_port_available_for_attach`), `native/ocx-child/src/toml_surgeon.rs` (`detect_unowned_root_routing_conflict`), and `src/server/management/link-enrollment.ts` (`apply` preflight/plant/connect error mapping). + - **Serialization**: `src/server/management/link-route-state.ts` (`fail(code, message, 409 | 502, hint)`). + - **Deserialization**: `src/cli/link-enrollment.ts` (`PHASES` / specific error messages) and `gui/src/remote-link-api.ts` (`LINK_ERROR_CODES` + `LinkApiError`). + - **Consumers**: `gui/src/pages/RemoteLink.tsx` (`ERROR_TKEY` -> localized `Notice` across all 11 `gui/src/i18n/*.ts` files) and `src/cli/link-enrollment.ts` (human-readable stderr recovery). +4. **Home Parity Digest on GUI Status (`homeCodexSettingsDigest` + `appliedCodexSettingsDigest`)**: + - **Creation**: `src/server/management/link-routes.ts` (`GET /api/link/status` computes `codexSettingsDigest(projectHomeCodexSettings(ctx.config, "0.0.0").settings)` for `ctx.principal === "gui-session"`, alongside existing `LinkChildParity.appliedCodexSettingsDigest`). + - **Serialization**: `Response.json(body)` on `GET /api/link/status` (omitted on `admin-token` responses so strict CLI `validateStatus` top-level keys stay unchanged). + - **Deserialization**: `gui/src/remote-link-api.ts` (`parseRemoteLinkStatus` extracts `homeCodexSettingsDigest: string | null` and per-row `appliedCodexSettingsDigest: string | null`). + - **Consumers**: `gui/src/pages/RemoteLink.tsx` (renders "Home settings synced" / "Sync needed" badge when both digests are non-null on a connected Child row). + +## 6. Early-Warning & Boundary Guard Disclosure (`PLAN-BYPASS-NAMED-01`) + +| Guard / Control | Tier | Executing Surface | Known Bypass Path | Residual Risk | Wording Policy | Final Layer | +|---|---|---|---|---|---|---| +| Managed-path check on `ocx-child detach --purge` | E2 (Runtime check) | `native/ocx-child/src/platform.rs` (`purge_managed_executable`) | Local Child user can manually `rm` any file they own over SSH | None for foreign paths: `ocx-child` refuses to unlink symlinks or paths outside `.cache/opencodex/bin` / `AppData\Local\opencodex\bin` | Described as a safety guard on `--purge`, never OS-level immutability | `native/ocx-child` `symlink_metadata` + parent path suffix match | +| `SHA256SUMS` check in `child-binary-source.ts` | E2 (Runtime check) | `src/link/child-binary-source.ts` | Same local OS user setting `OPENCODEX_CHILD_BIN_DIR` or rebuilding `native/ocx-child/target/release` locally | Local Home process user already owns SSH keys to Child; protects against corrupted/tampered downloads or cache files | Called artifact integrity verification against `SHA256SUMS` / local build | Exact byte-count + SHA-256 check on the Child plant script | +| Non-TTY `--plant` requirement in `ocx link add` | E2 (CLI check) | `src/cli/link-enrollment.ts` + `POST /api/link/apply` (`plant === true`) | Caller with admin token or paired GUI session explicitly passing `"plant": true` | Intentional: `plant: true` is the explicit opt-in contract | Called explicit plant opt-in guard | `POST /api/link/apply` server check (`body.plant === true`) | + +## 7. Grounded Verifiers (`PLAN-VERIFIER-REAL-01`) + +Every verifier below was executed or verified against the repository scripts on `2026-11-07`: + +1. `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` — baseline ran (`exit 0`, 8/8 tests passed); directly compiles and tests `native/ocx-child/src/**/*.rs` and `native/ocx-child/tests/*.rs`. +2. `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` — builds the real `native/ocx-child/target/release/ocx-child` binary consumed by `child-binary-source.ts` and E2E SSH plant/purge tests. +3. `bun run typecheck` (`package.json` -> `bun check --no-script --noEmit`) — baseline ran (`exit 0`, 2,319 files checked); directly typechecks `src/**/*.ts`, `tests/**/*.ts`, and `gui/src/**/*.tsx` including all 11 `gui/src/i18n/*.ts` `TKey` exhaustiveness checks. +4. `cd gui && bun run lint:i18n` (`oxlint src/pages src/components src/App.tsx src/main.tsx src/ui.tsx src/provider-workspace-data.ts`) — baseline ran (`exit 0`); enforces zero hardcoded UI strings in `gui/src/pages/RemoteLink*.tsx` and locale catalog parity. +5. `cd gui && bun run lint && bun test tests && bun run build` (plus root `bun run build:gui`) — directly lints, runs `gui/tests/remote-link.test.tsx` + `remote-link-route.test.tsx`, and bundles `gui/dist`. +6. `bun run test:changed` + explicit focused suites (`bun test tests/clients/link-*.test.ts tests/server/link-*.test.ts tests/cli/cli-link*.test.ts tests/ci-workflows/file-size-ratchet.test.ts tests/ci-workflows/repo-hygiene.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts`) — exercises module-graph + subprocess/layout/ratchet/hygiene gates. +7. `bun run skill:surface:check` — baseline ran (`exit 0`); verifies `src/cli/capabilities*.ts` and `skills/ocx/` stay synchronized. +8. `bun run structure:check` — baseline ran (`exit 0`); verifies `structure/remote-link.md`, `structure/manifest.json`, and `structure/INDEX.md`. +9. `cd docs-site && bun run build` — Astro + Starlight build verifying `docs-site/` remote-link user docs. +10. `bun run privacy:scan` — baseline ran (`exit 0`); scans tracked `src/`, `gui/`, `tests/`, `docs-site/`, and `devlog/` for accidental credentials or PII. + +## 8. Non-Goals, Alternatives Rejected, & Consequences + +- **Rejected SFTP/SCP upload for `ocx-child` planting**: OpenSSH `scp -O` vs SFTP protocol availability varies across minimal Alpine/BusyBox containers and restricted Windows OpenSSH setups, whereas `ssh -T ... sh -c` / `-EncodedCommand` with piped `stdin` uses the exact transport already proven by `probe` and `connect --key-stdin` and works 100% air-gapped. +- **Rejected modifying `.github/workflows/` or `scripts/release.ts`**: Out of lane write scope and requires maintainer release-pipeline security sign-off; `child-binary-source.ts` cleanly supports `OPENCODEX_CHILD_BIN_DIR`, packaged sidecars, local `target/release`, and `SHA256SUMS`-verified `$OPENCODEX_HOME/cache/ocx-child//` today. +- **Rejected committing UI screenshots onto `feat/ocx-child-plant-ux`**: Prohibited by `AGENTS.md` and `tests/ci-workflows/repo-hygiene.test.ts`; screenshots are committed strictly to `origin/pr-assets` and referenced by immutable commit SHA in the PR description. + diff --git a/devlog/_plan/261107_ocx_child_plant_ux/001_audit_and_status.md b/devlog/_plan/261107_ocx_child_plant_ux/001_audit_and_status.md new file mode 100644 index 00000000000..675a92bd818 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/001_audit_and_status.md @@ -0,0 +1,26 @@ +# Audit & Work-Phase Status Ledger (`261107_ocx_child_plant_ux`) + +> Reader contract: Durable audit, disclosure, and work-phase status record for `feat/ocx-child-plant-ux` (stacked on PR #8237 `feat/ocx-child-ephemeral-link`). + +`wp0` (docs-first roadmap cycle per `LOOP-DOCS-FIRST-01`) audited all five plan documents (`000_roadmap.md` and `010..040`) with zero Critical/High blockers (`VERDICT: PASS`) and folded all four Medium/Low refinements into `010..030` prior to implementation. + +## 1. `wp0` Audit Record & Same-Family Disclosure (`REVIEW-DECORRELATE-01`) + +- **Model inheritance disclosure**: Per explicit user instruction ("너 상속 으로 해서"), both architect (`01a1b2ed-664f-7180-99b1-fd1091864296`) and independent A-phase reviewer (`01a1b2f8-ffdf-77b0-a854-0141550b121d`) inherited the coordinator's model family (`google-antigravity/gemini-4-argon`); cross-family decorrelation was intentionally not used. +- **Reviewer `01a1b2f8-ffdf-77b0-a854-0141550b121d` verdict**: `VERDICT: PASS` (`blocking_issues: []`). +- **Dispositions of Medium/Low findings**: + 1. `M1` (Accepted & folded into `020`): Update `tests/server/link-enrollment-ephemeral-child.test.ts:192` (and `structure/remote-link.md:191` in `wp4`) from `"child_restart_unsupported"` to `"child_port_busy"` when `before.code === 0` on an ephemeral Child. + 2. `M2` (Accepted & folded into `010`): Prefix all three conflict branches in `native/ocx-child/src/toml_surgeon.rs:399,421,433` (including user-owned `model_catalog_json`) with `ocx-child-conflict:child_external_provider_conflict:`. + 3. `L3` (Accepted & folded into `020`/`030`): Keep `childKind` omitted on `POST /api/link/issue` client-initiated records so existing exact-store assertions stay green, and accept both legacy `{ alias, fingerprint, ocxVersion }` and extended `allowMissing` confirmation responses in `src/cli/link-enrollment.ts`. + 4. `L4` (Accepted & folded into `030`): Leave `src/cli/capabilities-base.ts` untouched so `skills/ocx/references/*` (outside `writeScope`) remains byte-identical and `bun run skill:surface:check` stays green. + +## 2. Work-Phase Status Table + +| Work Phase | Status | Summary & Verification Receipt | +|---|---|---| +| `wp0` | DONE | Docs-first roadmap (`000_roadmap.md`, `001_audit_and_status.md`, `010..040`) completed, audited (`VERDICT: PASS`), and verified with `privacy:scan` + `structure:check` (`exit 0`); zero production files modified | +| `wp1` | DONE | Rust `native/ocx-child` `--purge` & conflict prefixes + `src/link/` foundations (`9/9` Cargo tests, `51/51` Bun foundation & layout tests) | +| `wp2` | DONE | Home Management API `confirm-host`, `apply` plant/rollback, `status`, and `DELETE --purge` (`90/90` server, projection, and real-binary E2E tests) | +| `wp3` | DONE | CLI (`--plant`, `--purge`) & `#remote` GUI + 11 locales (`82/82` CLI link tests, `3738/3738` GUI tests, `lint:i18n`, `lint`, `build:gui`) | +| `wp4` | IN_PROGRESS | SoT docs (`structure/remote-link.md`, `docs-site/`), `origin/pr-assets` screenshots (`4d57c948b78bc66f2fb06b01cd9eb947766f1a60`), `35729/35729` `test:changed` pass, stacked PR & CI watch | + diff --git a/devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md b/devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md new file mode 100644 index 00000000000..338b39a1f9b --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/010_wp1_rust_purge_and_conflicts.md @@ -0,0 +1,197 @@ +# Work-Phase 1 (`wp1`): `native/ocx-child` `--purge` & Conflict Markers + `src/link/` Foundations + +> Depends on: `000_roadmap.md` (`ARCH-01..ARCH-05`). Consumed types: `LinkRecord`, `SshRunner`, `packedPowerShellArgv`, `ChildPaths`. Exported types: `LinkChildKind`, `ChildTargetTriple`, `RemoteRuntimeKind`, `ChildBinarySource`, `RemoteRuntimeInspection`. + +## 1. File Change Map + +| Action | Path | Summary | +|---|---|---| +| MODIFY | `native/ocx-child/src/platform.rs` | Add `PurgeExecutableOutcome`, `is_managed_plant_executable(exe_path, user_home, local_appdata)`, and `purge_managed_executable(exe_path, user_home, local_appdata)` (refusing symlinks/reparse points and foreign paths; POSIX `fs::remove_file`; Windows rename-aside + detached `cmd.exe /d /c` cleanup) | +| MODIFY | `native/ocx-child/src/daemon.rs` | Prefix `ensure_port_available_for_attach` conflict errors with `ocx-child-conflict:child_port_busy:` so Home maps foreign port ownership deterministically to `409 child_port_busy` | +| MODIFY | `native/ocx-child/src/toml_surgeon.rs` | Prefix `detect_unowned_root_routing_conflict` external provider / user `openai_base_url` errors with `ocx-child-conflict:child_external_provider_conflict:` | +| MODIFY | `native/ocx-child/src/cli.rs` | Extend `cmd_detach` (`detach` / `disconnect`) to accept `--purge` (reject unknown flags with exit `2`), drop `FileLock` and remove `lock_file` + empty `$OPENCODEX_HOME/child` dir, and run `purge_managed_executable` when `--purge` is passed | +| MODIFY | `native/ocx-child/tests/relay_and_lifecycle.rs` | Add Rust integration tests for `detach --purge` (managed path removed + empty `child` dir removed; foreign path preserved; symlink refused) and structured `child_port_busy` / `child_external_provider_conflict` stderr prefixes | +| MODIFY | `src/link/store.ts` | Add `export type LinkChildKind = "full" | "ephemeral"` and optional `childKind?: LinkChildKind` to `LinkRecord` + `"childKind"` in `RECORD_KEYS` | +| NEW | `src/link/child-binary-source.ts` | Resolve `ocx-child` binary for `ChildTargetTriple` across `OPENCODEX_CHILD_BIN_DIR`, sidecar dirs, `native/ocx-child/target/release` (when `targetTriple === hostChildTargetTriple()`), and `$OPENCODEX_HOME/cache/ocx-child//` with `SHA256SUMS` verification | +| MODIFY | `src/link/remote-windows.ts` | Add `windowsInspectRuntimeArgv()` and `windowsPlantChildArgv(expectedBytes, expectedSha256)` + `windowsPurgePlantedChildArgv()` staying strictly below `POWERSHELL_ENCODED_MAX = 8000` inside `OcxJob` | +| NEW | `src/link/remote-plant.ts` | Implement `ChildTargetTriple`, `RemoteRuntimeKind`, `parseTargetTriple`, `remoteInspectRuntimeArgv`, `inspectRemoteChildRuntime`, `remotePlantChildArgv`, `remotePurgePlantedChildArgv`, and `classifyChildAttachConflict` | +| NEW | `tests/clients/link-child-plant-foundations.test.ts` | Unit tests for `child-binary-source.ts` (`SHA256SUMS` pass/mismatch, symlink refusal, `OPENCODEX_CHILD_BIN_DIR`, host release fallback), `LinkRecord.childKind` roundtrip, and POSIX/Windows inspect & plant argv budgets (`<= 8000`) | +| MODIFY | `scripts/test-layout/layout-clients.json` & `tests/fixtures/test-layout-expected.json` | Register `tests/clients/link-child-plant-foundations.test.ts` | + +## 2. Diff-Level Specifications + +### 2.1 `native/ocx-child/src/platform.rs` & `cli.rs` (`--purge`) + +```diff +--- a/native/ocx-child/src/platform.rs ++++ b/native/ocx-child/src/platform.rs +@@ -876,5 +876,82 @@ pub fn resolve_codex_home() -> PathBuf { + if let Some(custom) = std::env::var_os("CODEX_HOME").filter(|v| !v.is_empty()) { + return PathBuf::from(custom); + } + resolve_user_home().join(".codex") + } ++ ++#[derive(Debug, Clone, PartialEq, Eq)] ++pub enum PurgeExecutableOutcome { ++ Removed, ++ SkippedUnmanagedPath, ++} ++ ++pub fn is_managed_plant_executable( ++ exe_path: &Path, ++ user_home: &Path, ++ local_appdata: Option<&Path>, ++) -> Result { ++ reject_symlink(exe_path)?; ++ let Some(parent) = exe_path.parent() else { return Ok(false); }; ++ reject_symlink(parent)?; ++ let expected_name = if cfg!(windows) { "ocx-child.exe" } else { "ocx-child" }; ++ if exe_path.file_name().and_then(|n| n.to_str()) != Some(expected_name) { ++ return Ok(false); ++ } ++ let canon_parent = fs::canonicalize(parent) ++ .map_err(|e| format!("failed canonicalizing {}: {e}", parent.display()))?; ++ let cache_bin = user_home.join(".cache").join("opencodex").join("bin"); ++ if fs::canonicalize(&cache_bin).ok().as_ref() == Some(&canon_parent) { ++ return Ok(true); ++ } ++ if let Some(appdata) = local_appdata { ++ let win_bin = appdata.join("opencodex").join("bin"); ++ if fs::canonicalize(&win_bin).ok().as_ref() == Some(&canon_parent) { ++ return Ok(true); ++ } ++ } ++ Ok(false) ++} ++ ++pub fn purge_managed_executable( ++ exe_path: &Path, ++ user_home: &Path, ++ local_appdata: Option<&Path>, ++) -> Result { ++ if !is_managed_plant_executable(exe_path, user_home, local_appdata)? { ++ return Ok(PurgeExecutableOutcome::SkippedUnmanagedPath); ++ } ++ #[cfg(unix)] ++ { ++ fs::remove_file(exe_path) ++ .map_err(|e| format!("failed removing planted ocx-child {}: {e}", exe_path.display()))?; ++ Ok(PurgeExecutableOutcome::Removed) ++ } ++ #[cfg(windows)] ++ { ++ use std::os::windows::process::CommandExt; ++ const CREATE_NO_WINDOW: u32 = 0x0800_0000; ++ const DETACHED_PROCESS: u32 = 0x0000_0008; ++ let parent = exe_path.parent().ok_or_else(|| "missing parent".to_owned())?; ++ let trashed = parent.join(format!(".ocx-child.purge.{}.old", std::process::id())); ++ let _ = fs::remove_file(&trashed); ++ fs::rename(exe_path, &trashed) ++ .map_err(|e| format!("failed renaming planted ocx-child for purge: {e}"))?; ++ let cmd_line = format!( ++ "ping -n 2 127.0.0.1 >nul & del /f /q "{}"", ++ trashed.display() ++ ); ++ let _ = Command::new("cmd.exe") ++ .args(["/d", "/c", &cmd_line]) ++ .stdin(Stdio::null()) ++ .stdout(Stdio::null()) ++ .stderr(Stdio::null()) ++ .creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS) ++ .spawn(); ++ Ok(PurgeExecutableOutcome::Removed) ++ } ++} +``` + +In `native/ocx-child/src/cli.rs` (`cmd_detach`): +- Parse `--keep-catalog`, `--json`, `--purge`; reject unknown flags with exit `2`. +- Hold `FileLock` during daemon stop, `offline_detach`, and removal of `key_file` + `state_file`; then explicitly `drop(lock)`, remove `paths.lock_file` and attempt `fs::remove_dir(&paths.child_dir)` (ignoring non-empty `Err`, plus `fs::remove_dir(&paths.opencodex_home)` only when empty) so a detached ephemeral Child leaves zero stale files in `$OPENCODEX_HOME/child/`. +- When `purge` is true, resolve `current_exe` (using `OCX_CHILD_SELF_EXE_FOR_TEST` override only under explicit test env or `std::env::current_exe()` — better: factor `detach_with_exe(args, current_exe_override: Option<&Path>)` for Rust library integration tests without any env-var backdoor in production!) and call `purge_managed_executable`, reporting `"purged": true | false` in JSON output. + +### 2.2 Conflict Markers in `daemon.rs` and `toml_surgeon.rs` + +```diff +--- a/native/ocx-child/src/daemon.rs ++++ b/native/ocx-child/src/daemon.rs +@@ -80,14 +80,14 @@ pub fn ensure_port_available_for_attach(port: u16, owned_pid: Option) -> Re + return Err(format!( +- "attach refused: 127.0.0.1:{port} is already served by foreign listener (pid {})", ++ "ocx-child-conflict:child_port_busy: attach refused: 127.0.0.1:{port} is already served by foreign listener (pid {})", + health.pid + )); + } + if TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], port))).is_err() { + return Err(format!( +- "attach refused: 127.0.0.1:{port} is already in use" ++ "ocx-child-conflict:child_port_busy: attach refused: 127.0.0.1:{port} is already in use" + )); + } +``` + +```diff +--- a/native/ocx-child/src/toml_surgeon.rs ++++ b/native/ocx-child/src/toml_surgeon.rs +@@ -399,7 +399,7 @@ pub fn detect_unowned_root_routing_conflict(content: &str) -> Result<(), String> + if let Some(ext) = external_codex_model_provider(content) { + return Err(format!( +- "attach refused: an external Codex model_provider ({ext}) owns config.toml" ++ "ocx-child-conflict:child_external_provider_conflict: attach refused: an external Codex model_provider ({ext}) owns config.toml" + )); + } +``` +(Apply the exact `ocx-child-conflict:child_external_provider_conflict:` prefix to all three conflict branches in `detect_unowned_root_routing_conflict` — `external_codex_model_provider` at `toml_surgeon.rs:399`, unmarked `openai_base_url` / `experimental_realtime_ws_base_url` at `toml_surgeon.rs:421`, and unmarked user-owned `model_catalog_json` at `toml_surgeon.rs:433` — preserving `"attach refused:"` so all existing tests stay green.) + +### 2.3 `src/link/store.ts`, `src/link/child-binary-source.ts`, and `src/link/remote-plant.ts` + +1. `src/link/store.ts`: + ```diff + +export type LinkChildKind = "full" | "ephemeral"; + + + export interface LinkRecord { + ... + createdAt: string; + remotePlatform?: "posix" | "windows"; + + childKind?: LinkChildKind; + } + -const RECORD_KEYS = new Set(["id", "alias", "direction", "hostKeyFingerprint", "tunnelPort", "apiKeyId", "createdAt", "remotePlatform"]); + +const RECORD_KEYS = new Set(["id", "alias", "direction", "hostKeyFingerprint", "tunnelPort", "apiKeyId", "createdAt", "remotePlatform", "childKind"]); + ``` +2. `src/link/child-binary-source.ts`: + - `export type ChildTargetTriple = "darwin-arm64" | "darwin-x64" | "linux-x64" | "linux-arm64" | "windows-x64"` + - `export type PlantUnavailableReason = "unsupported_target" | "artifact_not_found" | "checksum_mismatch"` + - `resolveChildBinarySource(targetTriple: ChildTargetTriple | null, options?: { env?: NodeJS.ProcessEnv; hostPlatform?: NodeJS.Platform; hostArch?: string; opencodexHome?: string; version?: string })`: + - Returns `{ ok: true, artifact: { targetTriple, bytes: Uint8Array, sizeBytes: number, sha256: string, source: "env" | "sidecar" | "local-release" | "cache" } }` or `{ ok: false, reason: PlantUnavailableReason }`. + - Artifact naming: `ocx-child-${targetTriple}${isWin ? ".exe" : ""}` (and in `OPENCODEX_CHILD_BIN_DIR` also accepts `

/${targetTriple}/ocx-child[.exe]` when `SHA256SUMS` lists either). + - `SHA256SUMS` requirement: for `env`, `sidecar`, and `cache` sources, requires a sibling `SHA256SUMS` file in the candidate directory containing `<64hex> ` (or `*`), verifies `lstatSync` is a regular non-symlink file (`<= 32 MiB`, `> 0` bytes, mode `& 0o022 === 0` on POSIX), reads the file once into `Uint8Array`, computes SHA-256 over those exact bytes, and returns `checksum_mismatch` if it diverges! For `local-release` (`native/ocx-child/target/release/ocx-child[.exe]` when `targetTriple === hostChildTargetTriple()`), verifies regular non-symlink file & permissions and hashes the read bytes directly. +3. `src/link/remote-plant.ts` & `src/link/remote-windows.ts`: + - `remoteInspectRuntimeArgv(platform)`: + - POSIX `sh -c`: emits `ocx-inspect:uname::`, evaluates candidate order identical to `REMOTE_OCX_SCRIPT`, distinguishes `full` vs `ephemeral` (selected path basename starting with `ocx-child` or `"$selected" --help` containing `(ocx-child`), and emits `ocx-inspect:kind:` + `ocx-inspect:version:` with exit `0`. + - Windows `packedPowerShellArgv`: emits `ocx-inspect:arch:<$env:PROCESSOR_ARCHITECTURE>`, checks `OCX_CANDIDATES`, and emits `ocx-inspect:kind:` + `ocx-inspect:version:` with exit `0`. + - `remotePlantChildArgv(platform, expectedBytes, expectedSha256)`: + - POSIX `sh -c`: `umask 077`, checks `[ ! -L "$HOME/.cache/opencodex" ] && [ ! -L "$HOME/.cache/opencodex/bin" ] && [ ! -L "$HOME/.cache/opencodex/bin/ocx-child" ]`, `mkdir -p "$HOME/.cache/opencodex/bin"`, `chmod 0700 "$HOME/.cache/opencodex/bin"`, writes stdin to `$tmp`, verifies `[ "$actual_bytes" = "$expected_bytes" ]`, verifies SHA-256 via `sha256sum` or `shasum -a 256` when available, `chmod 0700 "$tmp"`, `mv -f "$tmp" "$dest"`, and runs `"$dest" --version`. + - Windows `packedPowerShellArgv` (`< 8000` chars): inside `NATIVE_PROCESS` (`OcxJob`), resolves `$dir = if($env:LOCALAPPDATA){"$env:LOCALAPPDATA\opencodex\bin"}else{"$env:USERPROFILE\.cache\opencodex\bin"}`, refuses `ReparsePoint` on `$dir` or `$dest`, reads `[Console]::OpenStandardInput()` bounded to `expectedBytes + 1`, checks exact length and `[Security.Cryptography.SHA256]` lowercase hex digest against `expectedSha256`, moves atomically to `$dest`, and runs `[OcxJob]::Run($dest, '"'+$dest+'" --version')`. + +## 3. Conditional Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +- `--purge` on managed path vs foreign path vs symlink: Rust integration test invokes `detach_with_exe` on `.cache/opencodex/bin/ocx-child` (deletes binary + empty `child` dir), `/tmp/custom/ocx-child` (preserves binary), and symlinked `.cache/opencodex/bin/ocx-child` (refuses unlink). +- `SHA256SUMS` mismatch & symlink in `child-binary-source.ts`: unit test writes tampered `SHA256SUMS` -> observes `{ ok: false, reason: "checksum_mismatch" }`; writes symlink -> refused. +- `child_port_busy` & `child_external_provider_conflict`: Rust test binds a dummy TCP listener on `listen_port` or writes `model_provider = "custom"` -> observes exact `ocx-child-conflict:...` stderr prefix and untouched `config.toml`. + +## 4. `wp1` P-Phase Continuity & Architect Reflection Amendments (`01a1b2fe-fb7d-7f00-b3c9-756fbf5e0e0c`) + +- **Continuity from `wp0` D**: Resumed from `wp0` D conclusion ("Verified wp0 docs-only roadmap; next cycle wp1 starts foundations"). +- **Architect `01a1b2fe-fb7d-7f00-b3c9-756fbf5e0e0c` verdict**: `ALIGNED` on `W1-ARCH-01..W1-ARCH-03`. +- **Folded refinements for `B` implementation**: + 1. `src/link/store.ts` (`parseRecord`): validate `raw.childKind === undefined || raw.childKind === "full" || raw.childKind === "ephemeral"` and preserve `childKind` on the returned `LinkRecord` so `writeLinkStore` roundtrips it cleanly. + 2. `native/ocx-child/src/platform.rs`: verify `fs::symlink_metadata(exe_path)` is an existing regular file (`file_type().is_file()`), and on Windows pass `OCX_PURGE_TARGET` via `.env("OCX_PURGE_TARGET", &trashed)` to `cmd.exe /d /c "ping -n 2 127.0.0.1 >nul & del /f /q \"%OCX_PURGE_TARGET%\""` with `CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW | DETACHED_PROCESS`. + 3. `src/link/child-binary-source.ts`: verify `lstatSync` non-symlink status on candidate directory, `SHA256SUMS` (`<= 256 KiB`), and binary (`<= 32 MiB`, `> 0`, POSIX `(mode & 0o022) === 0`). + 4. `src/link/remote-plant.ts` & `src/link/remote-windows.ts`: include `remotePurgePlantedChildArgv(platform)` (invoking `detach --purge` when runnable and unlinking the managed binary after non-symlink/non-ReparsePoint checks) and `classifyChildAttachConflict(stderr)` matching `ocx-child-conflict:child_port_busy:` and `ocx-child-conflict:child_external_provider_conflict:`. + +## 5. `wp1` A-Phase Audit Fold-Backs (`01a1b309-5460-7c93-855b-d59ef8182224`, `GO-WITH-FIXES`) + +1. **`B1` (Folded — Windows `OcxJob` silent breakaway & `.old` sweep)**: Because `OcxJob` sets `JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK` without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, `purge_managed_executable` on Windows spawns `cmd.exe` with `CREATE_NO_WINDOW | DETACHED_PROCESS` (silent breakaway succeeds automatically; if a caller has explicit breakaway only, fallback is automatic), and both `windowsOcxArgv` (on `disconnect --purge`) and `windowsPurgePlantedChildArgv` sweep `.ocx-child.purge.*.old` in the managed bin dir after `Invoke-Native` exits. +2. **`B2` (Folded — Managed ancestor symlink/reparse checks)**: Canonicalize `user_home` / `local_appdata` root once, then reject symlinks/reparse points on every managed subpath component (`.cache`, `.cache/opencodex`, `.cache/opencodex/bin`, `ocx-child` and `opencodex`, `opencodex/bin`, `ocx-child.exe`) in Rust `is_managed_plant_executable` as well as POSIX and Windows plant/purge scripts. +3. **`B3` (Folded — `SHA256SUMS` fail-closed precedence)**: In `resolveChildBinarySource`, when a candidate binary exists in `env`, `sidecar`, or `cache` and fails `SHA256SUMS` (`checksum_mismatch`) or symlink/permission checks (including POSIX `(mode & 0o022) === 0` on `SHA256SUMS`), return `{ ok: false, reason: "checksum_mismatch" }` immediately without falling through to `local-release`. Also include `windowsPurgePlantedChildArgv()` in the `<= 8000` encoded-command budget test. + diff --git a/devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md b/devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md new file mode 100644 index 00000000000..2ac68ff1277 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/020_wp2_home_plant_store_and_api.md @@ -0,0 +1,117 @@ +# Work-Phase 2 (`wp2`): Home Management API — Inspection, SSH Plant & Rollback, `childKind` Projection, and `DELETE --purge` + +> Depends on: `000_roadmap.md`, `010_wp1_rust_purge_and_conflicts.md`. Consumed types: `LinkChildKind`, `ChildTargetTriple`, `RemoteRuntimeKind`, `ChildBinarySource`, `inspectRemoteChildRuntime`, `remotePlantChildArgv`, `remotePurgePlantedChildArgv`. + +## 1. File Change Map + +| Action | Path | Summary | +|---|---|---| +| MODIFY | `src/server/management/link-route-state.ts` | Extend `PendingLinkHost` and `ConfirmedLinkHost` with optional `runtimeKind`, `targetTriple`, `plantable`, `binarySizeBytes`, `plantUnavailableReason` | +| MODIFY | `src/server/management/link-enrollment.ts` | Extend `probe` and `confirmHost` with `allowMissing?: boolean`; extend `apply` with `plant?: boolean`, streaming verified `ocx-child` bytes over SSH `stdin`, rolling back newly planted binary on failure, persisting `childKind`, and mapping `child_port_busy` / `child_external_provider_conflict` to `409` | +| MODIFY | `src/link/status-projection.ts` | Project optional `childKind?: "full" | "ephemeral"` on `LinkStatusDto["links"][number]` | +| MODIFY | `src/server/management/link-routes.ts` | Extend `GET /api/link/status` (`homeCodexSettingsDigest` on `gui-session`) and `DELETE /api/link/:id` (`{ force?: boolean, purge?: boolean }`, passing `["disconnect", "--purge"]` when `purge === true && record.childKind === "ephemeral"`) | +| MODIFY | `tests/server/link-enrollment-ephemeral-child.test.ts` | Add server route tests for `confirm-host` (`allowMissing: true` across `missing`/`ephemeral`/`full`), `apply` (`plant: true` success, missing `plant` refusal, post-plant failure rollback, `child_port_busy`, `child_external_provider_conflict`), `status` (`childKind` + `homeCodexSettingsDigest`), and `DELETE` (`purge: true` vs default vs `force+purge` `400`) | +| MODIFY | `tests/clients/link-ocx-child-e2e.test.ts` | Add real-binary E2E case streaming compiled `ocx-child` into a bare Child `$HOME/.cache/opencodex/bin/ocx-child`, enrolling, relaying, and running `disconnect --purge` to verify zero leftover binary or `child/` directory | + +## 2. Diff-Level Specifications + +### 2.1 `src/server/management/link-route-state.ts` + +```diff +--- a/src/server/management/link-route-state.ts ++++ b/src/server/management/link-route-state.ts +@@ -18,10 +18,16 @@ export interface PendingLinkHost { + probedAt: number; + sshLatencyMs?: number; + remotePlatform?: "posix" | "windows"; ++ runtimeKind?: "full" | "ephemeral" | "missing"; ++ targetTriple?: import("../../link/child-binary-source").ChildTargetTriple | null; + } + + export interface ConfirmedLinkHost extends PendingLinkHost { +- ocxVersion: string; ++ ocxVersion: string | null; ++ plantable?: boolean; ++ binarySizeBytes?: number; ++ plantUnavailableReason?: import("../../link/child-binary-source").PlantUnavailableReason; + } +``` + +### 2.2 `src/server/management/link-enrollment.ts` + +1. **`probe`**: + - Accept `{ alias }`, `{ alias, includeVersion }`, or `{ alias, includeVersion, allowMissing }` (`includeVersion` and `allowMissing` must be `boolean` when present). + - When `includeVersion === true && allowMissing !== true`: unchanged legacy behavior (`remoteOcxArgv(["--version"], remotePlatform)` -> `{ alias, fingerprint, keyType, ocxVersion }`). + - When `includeVersion === true && allowMissing === true`: run `inspectRemoteChildRuntime(runner, alias, path, remotePlatform)`. + - If `runtimeKind === "full"` or `"ephemeral"`: verify `MIN_REMOTE_OCX` (`>= 2.66.0`, else `409 remote_ocx_outdated`), store `runtimeKind` and `targetTriple` on `PendingLinkHost`, and return `{ alias, fingerprint, keyType, ocxVersion, runtimeKind, targetTriple, plantable: false }`. + - If `runtimeKind === "missing"`: resolve `resolveChildBinarySource(targetTriple)`, store on `PendingLinkHost`, and return `{ alias, fingerprint, keyType, ocxVersion: null, runtimeKind: "missing", targetTriple, plantable, ...(binarySizeBytes !== undefined ? { binarySizeBytes } : {}), ...(plantUnavailableReason ? { plantUnavailableReason } : {}) }`. + +2. **`confirmHost`**: + - Accept `{ alias, fingerprint }` or `{ alias, fingerprint, allowMissing }`. + - When `allowMissing !== true`: preserve exact legacy `remoteOcxArgv(["--version"], remotePlatform)` call and exact `{ alias, fingerprint, ocxVersion }` JSON response! + - When `allowMissing === true`: run `inspectRemoteChildRuntime(runner, alias, path, remotePlatform)`: + - If `runtimeKind !== "missing"`: check `MIN_REMOTE_OCX`, pin host in `known_hosts`, store `ConfirmedLinkHost` with `runtimeKind`, `targetTriple`, `ocxVersion`, `plantable: false`, and return `{ alias, fingerprint, ocxVersion, runtimeKind, targetTriple, plantable: false }`. + - If `runtimeKind === "missing"`: resolve `resolveChildBinarySource(targetTriple)`, pin host in `known_hosts`, store `ConfirmedLinkHost` with `ocxVersion: null, runtimeKind: "missing", targetTriple, plantable, binarySizeBytes, plantUnavailableReason`, and return that object. + +3. **`apply`**: + - Accept `alias`, optional `fingerprint`, and optional `plant?: boolean`. + - If `confirmed.runtimeKind === "missing"` and `body.plant !== true`: return `fail("remote_ocx_missing", "ocx was not found on the remote host; confirm planting ocx-child before applying.", 409)`. + - Track `let plantedFreshBinary = false`. + - When `body.plant === true && confirmed.runtimeKind === "missing"`: + - `phase = "remote_plant"`; re-resolve `resolveChildBinarySource(confirmed.targetTriple ?? null)` (reading verified `Uint8Array` bytes + `sha256` right before streaming). If `!source.ok`, return `fail("remote_plant_unavailable", "No verified ocx-child binary is available for this host architecture.", 409, source.reason)`. + - Run `runner.run(buildExecArgv({ alias, argv: remotePlantChildArgv(remotePlatform, source.artifact.sizeBytes, source.artifact.sha256), knownHostsFile: knownHosts }), { stdin: source.artifact.bytes, timeoutMs: Math.max(budget, 60_000) })`. + - If nonzero exit, return `fail("remote_plant_failed", "Could not plant ocx-child on the remote host.", 502, sshFailureHint(plantRes.stderr))`. + - Set `plantedFreshBinary = true`. + - Extend `failed(response)` helper so whenever `plantedFreshBinary === true`, after `compensateNewLink` revokes any issued key and stops the tunnel, Home runs best-effort `run(remotePurgePlantedChildArgv(remotePlatform))` (which invokes `ocx-child detach --purge` if state exists and removes the newly planted managed binary) so failed enrollments leave the bare host untouched! + - Conflict mapping in `apply`: + - During `child_preflight` for `ephemeralChild`: when `before.code === 0` (or a TCP listener probe answers on `childPort`), return `fail("child_port_busy", "Port " + childPort + " is already in use on the Child; no Child files were modified.", 409)` (before any key is issued!). + - During `remote_connect`: if `connected.code !== 0`, inspect `classifyChildAttachConflict(connected.stderr)`: + - `"child_port_busy"` -> compensate (and purge if freshly planted; `remoteMayBeConnected = false` because `ocx-child` exited before writing state/files!) and return `fail("child_port_busy", "The Child's target port is already in use; no Child files were modified.", 409, sshFailureHint(connected.stderr))`. + - `"child_external_provider_conflict"` -> compensate (`remoteMayBeConnected = false` because `detect_unowned_root_routing_conflict` runs before journal/config writes!) and return `fail("child_external_provider_conflict", "The Child's ~/.codex/config.toml is already managed by another model provider or custom base URL; no Child files were modified.", 409, sshFailureHint(connected.stderr))`. + - Persist `childKind: ephemeralChild ? "ephemeral" : "full"` on hub-initiated `apply` `record` in `links.json` (leaving `POST /api/link/issue` client-initiated records without `childKind` so existing exact-store fixtures in `tests/server/link-management-routes.test.ts` stay untouched). + - Update `tests/server/link-enrollment-ephemeral-child.test.ts:192` assertion from `"child_restart_unsupported"` to `"child_port_busy"` on ephemeral foreign-listener preflight. + +### 2.3 `src/link/status-projection.ts` & `src/server/management/link-routes.ts` + +```diff +--- a/src/link/status-projection.ts ++++ b/src/link/status-projection.ts +@@ -20,6 +20,7 @@ export interface LinkStatusDto { + since: string; + reason: string | null; + tunnelPort: number; ++ childKind?: "full" | "ephemeral"; + /** Home only: what the link carried from this Child, and the compatibility verdict. */ + peer?: LinkPeerStatus; +@@ -141,6 +142,7 @@ export function projectLinkStatus( + ...stateDetails(record, byId.get(record.id), compensation), + tunnelPort: record.tunnelPort, ++ ...(record.childKind ? { childKind: record.childKind } : {}), + ...parity(record.id), + })); +``` + +In `src/server/management/link-routes.ts`: +- In `remove`: validate `body` keys `<= ["force", "purge"]`, both optional booleans, and reject `force === true && purge === true` with `400 invalid_body`. When `!force && record.direction === "hub-initiated"`, pass `purge === true && record.childKind === "ephemeral" ? ["disconnect", "--purge"] : ["disconnect"]` to `remoteOcxArgv`. +- In `GET /api/link/status`: when `ctx.principal === "gui-session"`, include `homeCodexSettingsDigest: codexSettingsDigest(projectHomeCodexSettings(ctx.config, packageVersion("0.0.0")).settings)` alongside `joinAvailable` and `joinDenied`. + +## 3. Conditional Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +- Bare host `apply` without `plant: true` -> `409 remote_ocx_missing`, zero SSH writes. +- Bare host `apply` with `plant: true` + simulated `child_relay_failed` -> key revoked, tunnel stopped, and `remotePurgePlantedChildArgv` invoked to remove planted binary. +- `ocx-child` `connect` returning `ocx-child-conflict:child_external_provider_conflict` -> `409 child_external_provider_conflict` without `residual: true` and with link compensated cleanly. + +## 4. `wp2` P-Phase Continuity & Architect Reflection Amendments (`01a1b316-9e8c-7a23-845e-da43f6554f0e`) + +- **Continuity from `wp1` D**: Resumed from `wp1` D conclusion ("Next cycle wp2 implements Home Management API enrollment/plant/purge routes"). +- **Architect `01a1b316-9e8c-7a23-845e-da43f6554f0e` verdict**: `ALIGNED` on `W2-ARCH-01..W2-ARCH-03`. +- **Folded refinements**: + 1. Add `resolveChildBinarySource?: typeof import("../../link/child-binary-source").resolveChildBinarySource` to `ManagementApiDeps` in `src/server/management/context.ts` so server route tests can deterministically inject verified/unavailable binary resolutions across targets. + 2. In `apply` (`src/server/management/link-enrollment.ts`), route EVERY exit after `plantedFreshBinary = true` (including `remote_port` and `child_preflight` refusals such as `child_port_busy` and `child_curl_missing`) through `await failed(...)` so newly planted binaries are always purged on failure; keep `confirmedHosts` intact on pre-plant `409 remote_ocx_missing` / `remote_plant_unavailable`; and reset `remoteMayBeConnected = false` on classified pre-write attach conflicts (`child_port_busy`, `child_external_provider_conflict`). + 3. Enforce strict key sets: `probe` requires `includeVersion === true` when `allowMissing` is supplied; `confirmHost` accepts `{ alias, fingerprint }` or `{ alias, fingerprint, allowMissing }`; `apply` accepts `{ alias, fingerprint?, plant? }` (all 4 exact subsets); `DELETE /api/link/:id` accepts empty/null body or `{ force?, purge? }` and rejects `force === true && purge === true` with `400 invalid_body`. + +## 5. `wp2` A-Phase Audit Fold-Backs (`01a1b322-c1e1-7c41-a560-45f51e35dca3`, `GO-WITH-FIXES`) + +1. **`W2-BLK-01` (Folded — Purge exception safety & `residual` reconciliation)**: Wrap `remotePurgePlantedChildArgv` inside `failed()` in a once-only `try/catch` (`plantedFreshBinary = false`). If rollback purge exits `0`, reset `remoteMayBeConnected = false` (no residual left on Child); if rollback purge returns nonzero or throws, set `remoteMayBeConnected = true` with recovery `"Run ~/.cache/opencodex/bin/ocx-child detach --purge on the Child before retrying."`. +2. **`W2-BLK-02` (Folded — Public DTO projection, boolean guards, & `context.ts` seam)**: Add `src/server/management/context.ts` (`resolveChildBinarySource` seam) to the File Change Map, project only explicit public DTO fields from `confirmHost` (never leaking `knownHostLine`, `probedAt`, `sshLatencyMs`, or `remotePlatform`), validate `typeof allowMissing === "boolean"` in `probe`/`confirmHost` and `typeof plant === "boolean"` in `apply` (`400 invalid_body`), and wire `ctx.deps.resolveChildBinarySource ?? resolveChildBinarySource` in all three handlers. + diff --git a/devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md b/devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md new file mode 100644 index 00000000000..d8c42f9edb1 --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/030_wp3_cli_and_gui_remote_ux.md @@ -0,0 +1,124 @@ +# Work-Phase 3 (`wp3`): CLI (`--plant`, `--purge`) & Dashboard `#remote` UX Across All 11 Locales + +> Depends on: `000_roadmap.md`, `010_wp1_rust_purge_and_conflicts.md`, `020_wp2_home_plant_store_and_api.md`. Consumed types: `LinkStatusDto`, `LinkChildKind`, `ChildTargetTriple`, `RemoteRuntimeKind`, `PlantUnavailableReason`. + +## 1. File Change Map + +| Action | Path | Summary | +|---|---|---| +| MODIFY | `src/cli/link-enrollment.ts` | Add `--plant` to `ocx link add`, probe/confirm with `allowMissing: true`, interactive TTY bare-host plant prompt (`Confirm — plant lightweight ocx-child () and connect? (type yes): `), non-TTY `--plant` requirement on bare hosts, and actionable messages for `child_port_busy` / `child_external_provider_conflict` / `remote_plant_*` | +| MODIFY | `src/cli/link.ts` | Update `LINK_USAGE` (keeping `src/cli/capabilities-base.ts` untouched so `skills/ocx/references/*` outside `writeScope` stays current), accept optional `childKind` in `validateStatus`, add `--purge` to `ocx link revoke` (mutually exclusive with `--force`), and emit `~/.cache/opencodex/bin/ocx-child detach` recovery guidance on `--force` when `childKind === "ephemeral"` | +| MODIFY | `gui/src/remote-link-api.ts` | Add `child_port_busy`, `child_external_provider_conflict`, `remote_plant_failed`, `remote_plant_unavailable` to `LINK_ERROR_CODES`; extend `LinkConfirmHostView`, `LinkRowWire` (`childKind`, `appliedCodexSettingsDigest`), and `RemoteLinkStatusWire` (`homeCodexSettingsDigest`) | +| NEW | `gui/src/pages/RemoteLinkSheet.tsx` | Extracted Add Child / Find Home `` sheet with Ready-to-Plant card (architecture, formatted ~0.9 MB size, zero-Node/Bun cache explanation, primary CTA `link.plant.apply`), Cached Ephemeral card (`link.ephemeralReady`), Full OpenCodex card, and unavailable-plant notice | +| NEW | `gui/src/pages/RemoteLinkDialogs.tsx` | Extracted Disconnect / Force-Remove `` with Ephemeral fast-detach explanation + `--purge` checkbox (`remoteLink.disconnect.purgeLabel`), and `~/.cache/opencodex/bin/ocx-child detach` `` for forced ephemeral removal | +| MODIFY | `gui/src/pages/RemoteLink.tsx` | Wire `allowMissing: true` on Home `confirmHost`, `plant: true` when `confirmation.runtimeKind === "missing"`, Connected Child row mode badge (`remoteLink.badge.ephemeral` vs `remoteLink.badge.full`) & parity badge (`remoteLink.parity.synced` vs `remoteLink.parity.outdated`), `purge` state in `removeLink`, and new conflict `ERROR_TKEY` mappings | +| MODIFY | `gui/src/styles-remote-link.css` | Add token-based styles for Ready-to-Plant card (`.remote-link-plant-card`), row badges (`.remote-link-badges`), and purge checkbox row (`.remote-link-purge-option`) without touching `gui/src/styles.css` | +| MODIFY | `gui/src/i18n/{en,ko,ja,zh,zh-TW,de,fr,pt,ru,tr,vi}.ts` | Add all new `link.plant.*`, `remoteLink.badge.*`, `remoteLink.parity.*`, `remoteLink.disconnect.*`, and `remoteLink.error.*` keys across all 11 locales (patched in 2–3 file batches) | +| MODIFY | `tests/cli/cli-link-enrollment.test.ts` & `tests/cli/cli-link-force.test.ts` (or `cli-link.test.ts`) | Add CLI tests for `ocx link add --plant` (TTY prompt, non-TTY refusal without `--plant`, non-TTY success with `--plant`), conflict errors, and `ocx link revoke --purge` + ephemeral `--force` recovery guidance | +| MODIFY | `gui/tests/remote-link.test.tsx` | Update Home `confirm-host` `allowMissing: true` body assertions (`lines 1027, 1046, 1048, 1111`) and add tests for Ready-to-Plant card, Cached Ephemeral card, Connected mode/parity badges, `child_port_busy` / `child_external_provider_conflict` notices, and Ephemeral Disconnect `--purge` checkbox + force-remove command | + +## 2. Diff-Level Specifications + +### 2.1 CLI (`src/cli/link-enrollment.ts` & `src/cli/link.ts`) + +- `src/cli/link-enrollment.ts`: + ```diff + const wantsJson = takeJsonFlag(args); + +const plantFlag = takeFlag(args, "--plant"); + const expected = takeOption(args, "--expect-fingerprint"); + ... + +if (command === "probe" && plantFlag) throw new CliUsageError("--plant is only for link add", usage); + ``` + - For `command === "probe"`: send `{ alias, includeVersion: true }` (unchanged). + - For `command === "add"`: send `{ alias, includeVersion: true, allowMissing: true }`. + - Parse `probe` response accepting legacy `{ alias, fingerprint, keyType, ocxVersion }` (`runtimeKind` treated as `"full"`) or extended `{ alias, fingerprint, keyType, ocxVersion: string | null, runtimeKind, targetTriple, plantable, binarySizeBytes?, plantUnavailableReason? }`. + - When `probe.runtimeKind === "missing"`: + - If `probe.plantable !== true`: throw `new Error("Cannot plant ocx-child on " + alias + " (" + (probe.targetTriple ?? "unknown target") + "): " + (probe.plantUnavailableReason ?? "artifact unavailable") + ".")`. + - Format size helper `formatSizeMb(bytes)` -> e.g. `"~0.9 MB"` (`(bytes / (1024 * 1024)).toFixed(1) + " MB"`). + - If `!isTTY && !plantFlag`: throw `new CliUsageError("Remote host has no OpenCodex runtime; pass --plant alongside --expect-fingerprint to plant lightweight ocx-child.", usage)`. + - If `!expected` (TTY): prompt `Confirm ${alias} ${probe.keyType} ${probe.fingerprint} — plant lightweight ocx-child (${sizeLabel}) and connect? (type yes): ` and set `shouldPlant = true` on `"yes"`. + - Else if `!plantFlag` (TTY with `--expect-fingerprint`): prompt `Plant lightweight ocx-child (${sizeLabel}) on ${alias} and connect? (type yes): ` and set `shouldPlant = true` on `"yes"`. + - Else (`plantFlag`): `shouldPlant = true`. + - Call `POST /api/link/confirm-host` with `{ alias, fingerprint: probe.fingerprint, ...(shouldPlant || probe.runtimeKind ? { allowMissing: true } : {}) }`. + - Call `POST /api/link/apply` with `{ alias, fingerprint: probe.fingerprint, ...(shouldPlant ? { plant: true } : {}) }`. + +- `src/cli/link.ts`: + - `validateStatus`: `assertKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], [...PARITY_KEYS, "peer", "tunnel", "childKind"], ...)`, validating `candidate.childKind === undefined || candidate.childKind === "full" || candidate.childKind === "ephemeral"`. + - `runRevoke`: + - `const purge = takeFlag(args, "--purge");` + - `if (purge && force) throw new CliUsageError("--purge cannot be combined with --force", LINK_USAGE);` + - When `force`: inspect local store (`(deps.readStore ?? (() => { try { return readLinkStore(linkStorePath()); } catch { return emptyLinkStore(); } }))()`) before `DELETE` to check if `record?.childKind === "ephemeral"`; if `"ephemeral"`, set `recovery = "Run ~/.cache/opencodex/bin/ocx-child detach on the remote client."`! + - When `purge`: send `DELETE /api/link/` with `{ method: "DELETE", redirect: "error", headers: { "content-type": "application/json" }, body: JSON.stringify({ purge: true }) }`. + +### 2.2 GUI `#remote` Wire & Components (`gui/src/remote-link-api.ts`, `RemoteLink.tsx`, `RemoteLinkSheet.tsx`, `RemoteLinkDialogs.tsx`) + +- `gui/src/remote-link-api.ts`: + ```ts + export type LinkRuntimeKind = "full" | "ephemeral" | "missing"; + export interface LinkConfirmHostView { + alias: string; + fingerprint: string; + ocxVersion: string | null; + runtimeKind: LinkRuntimeKind; + targetTriple: string | null; + plantable: boolean; + binarySizeBytes: number | null; + plantUnavailableReason: string | null; + } + export interface LinkRowWire { + id: string; + alias: string; + direction: LinkWireDirection; + state: LinkWireState; + since: string; + reason: string | null; + tunnelPort: number; + childKind: "full" | "ephemeral" | null; + appliedCodexSettingsDigest: string | null; + } + export interface RemoteLinkStatusWire { + ... + homeCodexSettingsDigest: string | null; + } + ``` +- New i18n keys (added identically across all 11 `gui/src/i18n/*.ts` files): + - `link.plant.title`: EN `"Ready to plant lightweight agent"` / KO `"경량 에이전트 설치 준비됨"` + - `link.plant.meta`: EN `"{arch} · {size} · ~/.cache/opencodex/bin/ocx-child"` / KO `"{arch} · {size} · ~/.cache/opencodex/bin/ocx-child"` + - `link.plant.body`: EN `"OpenCodex is not installed on this computer yet. Plant the standalone ocx-child binary (~1 MB, no Node or Bun required) into ~/.cache/opencodex/bin over SSH and connect immediately. You can remove it cleanly when disconnecting."` / KO `"원격 컴퓨터에 아직 OpenCodex가 없습니다. Node나 Bun 없이 동작하는 단일 경량 파일(~1 MB)을 ~/.cache/opencodex/bin에 심어 즉시 연결하며, 연결 해제 시 깔끔하게 지울 수 있습니다."` + - `link.plant.apply`: EN `"Plant lightweight agent & connect"` / KO `"경량 에이전트 심고 연결"` + - `link.plant.applying`: EN `"Planting & connecting…"` / KO `"경량 에이전트 심고 연결 중…"` + - `link.plant.unavailable`: EN `"OpenCodex was not found on this host ({arch}), and no verified ocx-child binary is available locally to plant."` / KO `"원격 컴퓨터({arch})에 OpenCodex가 없으며, 이 컴퓨터에서 심을 수 있는 검증된 경량 에이전트 파일을 찾지 못했습니다."` + - `link.ephemeralReady`: EN `"Lightweight agent ready (ocx-child {version})"` / KO `"경량 에이전트 준비됨 (ocx-child {version})"` + - `remoteLink.badge.ephemeral`: EN `"Lightweight"` / KO `"경량 모듈"` + - `remoteLink.badge.full`: EN `"Full install"` / KO `"전체 설치"` + - `remoteLink.parity.synced`: EN `"Home settings synced"` / KO `"Home 설정 일치"` + - `remoteLink.parity.outdated`: EN `"Sync needed"` / KO `"동기화 필요"` + - `remoteLink.disconnect.ephemeralBody`: EN `"Disconnect {alias}? Its Codex configuration will be restored and the relay process will stop immediately (0 background processes). By default, the cached ocx-child binary stays in ~/.cache/opencodex/bin for fast reconnects."` / KO `"{alias} 연결을 해제할까요? 원격 Codex 설정이 원래대로 복원되고 백그라운드 프로세스 0개 상태로 종료됩니다. 기본적으로 빠른 재연결을 위해 경량 에이전트 파일은 캐시에 남겨둡니다."` + - `remoteLink.disconnect.purgeLabel`: EN `"Also delete the planted lightweight agent binary from the remote computer"` / KO `"심어둔 경량 에이전트 파일까지 원격 컴퓨터에서 함께 삭제"` + - `remoteLink.disconnect.purgeConfirm`: EN `"Delete & disconnect"` / KO `"파일 삭제 후 연결 해제"` + - `remoteLink.error.child_port_busy`: EN `"The target port (10100) on the Child computer is already in use by another process. No files on the Child were modified. Free the port or stop the conflicting process, then retry."` / KO `"원격 컴퓨터의 대상 포트(10100)를 이미 다른 프로세스가 사용 중입니다. 원격 파일은 변경되지 않았습니다. 포트를 비운 뒤 다시 시도하세요."` + - `remoteLink.error.child_external_provider_conflict`: EN `"~/.codex/config.toml on the Child computer is already managed by another model provider or custom base URL. No files on the Child were modified."` / KO `"원격 컴퓨터의 ~/.codex/config.toml을 이미 다른 모델 제공자나 사용자 지정 주소가 소유하고 있습니다. 원격 파일은 변경되지 않았습니다."` + - `remoteLink.error.remote_plant_failed`: EN `"Could not plant the lightweight ocx-child agent over SSH. Check remote disk space and ~/.cache permissions, then retry."` / KO `"SSH를 통해 경량 에이전트(ocx-child)를 심지 못했습니다. 원격 디스크 여유 공간과 ~/.cache 권한을 확인한 뒤 다시 시도하세요."` + - `remoteLink.error.remote_plant_unavailable`: EN `"No verified ocx-child binary is available for the remote computer's architecture."` / KO `"원격 컴퓨터 아키텍처에 맞는 검증된 경량 에이전트 파일이 없습니다."` + +## 3. Conditional Activation Scenarios (`C-ACTIVATION-GROUNDING-01`) + +- Ready-to-Plant modal -> clicking `Plant lightweight agent & connect` sends `POST /api/link/apply` with `{ alias, plant: true }`. +- Connected list -> row with `childKind: "ephemeral"` and matching `appliedCodexSettingsDigest === homeCodexSettingsDigest` renders `Lightweight` + `Home settings synced`; mismatched digest renders `Sync needed`. +- Ephemeral Disconnect dialog -> checking purge checkbox sends `DELETE /api/link/` with `{ purge: true }`; unchecking sends bodyless `DELETE /api/link/`; `remote_disconnect_failed` transitions to Force Remove rendering `~/.cache/opencodex/bin/ocx-child detach`. + +## 4. `wp3` P-Phase Continuity & Architect Reflection Amendments (`01a1b32b-f0db-7e73-93d5-8578c4fbf55d`) + +- **Continuity from `wp2` D**: Resumed from `wp2` D conclusion ("Next cycle wp3 implements CLI (--plant, --purge) and #remote GUI + 11 locales"). +- **Architect `01a1b32b-f0db-7e73-93d5-8578c4fbf55d` verdict**: `ALIGNED` on `W3-ARCH-01..W3-ARCH-03`. +- **Folded refinements**: + 1. Gate CLI `confirm-host` `{ allowMissing: true }` on `shouldPlant || probe.runtimeKind === "missing"` so existing full-runtime CLI expectations stay unchanged, and accept both legacy 3-key and extended `allowMissing` confirmation payloads. + 2. Map `child_port_busy`, `child_external_provider_conflict`, `remote_plant_failed`, `remote_plant_timeout`, and `remote_plant_unavailable` in CLI `PHASES` / actionable error messages, and emit `~/.cache/opencodex/bin/ocx-child detach` residual recovery when `shouldPlant` or `runtimeKind === "ephemeral"` is active. + 3. Explicitly parse and null-default `childKind`, `appliedCodexSettingsDigest`, and `homeCodexSettingsDigest` in `parseRemoteLinkStatus` (`gui/src/remote-link-api.ts`), and reset `purge` checkbox state to `false` whenever the Disconnect dialog opens or closes. + +## 5. `wp3` A-Phase Audit Fold-Backs (`01a1b32d-f03c-79f0-ba27-9cf46b2708ec`, `GO-WITH-FIXES`) + +1. **`remote_plant_timeout` & zero hardcoded strings in `src/pages/*`**: Add `remote_plant_timeout` to `LINK_ERROR_CODES`, `ERROR_TKEY`, and all 11 locale catalogs (`remoteLink.error.remote_plant_timeout`); add `link.plant.unknownArch` across all 11 locales for `targetTriple === null`; and place `formatBinaryMegabytes` inside `gui/src/remote-link-api.ts` so `src/pages/*` passes `bun run lint:i18n` with zero allowlist additions. +2. **Strict `probe.runtimeKind === "missing"` CLI gate**: Gate CLI `confirm-host` `{ allowMissing: true }` strictly on `shouldPlant || probe.runtimeKind === "missing"`. +3. **Null guards & force/purge isolation**: Render parity badge only when `row.appliedCodexSettingsDigest !== null && status.homeCodexSettingsDigest !== null`; omit Apply CTA when `runtimeKind === "missing" && !plantable`; send `{ purge: true }` only when `!pending.force && purge && pending.row.childKind === "ephemeral"`, and send `{ force: true }` exclusively when `pending.force` is true. + diff --git a/devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md b/devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md new file mode 100644 index 00000000000..196978b1c7a --- /dev/null +++ b/devlog/_plan/261107_ocx_child_plant_ux/040_wp4_docs_screenshots_and_pr.md @@ -0,0 +1,48 @@ +# Work-Phase 4 (`wp4`): SoT Docs Sync, `pr-assets` UI Screenshots, Full Gates, and Stacked PR Delivery + +> Depends on: `000_roadmap.md`, `010..030`. + +## 1. File Change Map + +| Action | Path | Summary | +|---|---|---| +| MODIFY | `structure/remote-link.md` | Update present-tense contract under `## Ephemeral Rust Child (ocx-child)` to document `src/link/child-binary-source.ts`, `src/link/remote-plant.ts`, `allowMissing` host inspection, `POST /api/link/apply` (`plant: true`) SSH `stdin` planting & rollback, `--purge` managed-path removal, `LinkRecord.childKind`, and `child_port_busy` / `child_external_provider_conflict` | +| MODIFY | `docs-site/src/content/docs/guides/remote-link.md` (and matching locale pages if present) | Document zero-preinstall SSH Child onboarding (`#remote` Ready-to-Plant & `ocx link add --plant`), two-tier disconnect (`--purge`), and conflict troubleshooting | +| EXTERNAL | `origin/pr-assets` (`pr-ocx-child-ux/*.png`) | Commit and push 3 crisp `#remote` UI screenshots ONLY to `origin/pr-assets` via a temporary git worktree outside the repo tree (zero image files on `feat/ocx-child-plant-ux`) | + +## 2. Execution & Delivery Steps + +1. **Documentation & SSOT gates**: + - Update `structure/remote-link.md` naming `src/link/child-binary-source.ts`, `src/link/remote-plant.ts`, and `tests/clients/link-child-plant-foundations.test.ts`; stage new files with `git add` and verify `bun run structure:check` (`exit 0`). + - Inspect `rg -l "remote-link|ocx-child" docs-site/`, update user-facing Remote Link docs, and verify `cd docs-site && bun run build` (`exit 0`). +2. **Full Local Verification Floor**: + - `cargo test --locked --manifest-path native/ocx-child/Cargo.toml` + - `cargo build --release --locked --manifest-path native/ocx-child/Cargo.toml` + - `cd gui && bun run lint:i18n && bun run lint && bun test tests && bun run build` + - `bun run build:gui` + - `bun run typecheck` + - `bun test tests/clients/link-*.test.ts tests/server/link-*.test.ts tests/cli/cli-link*.test.ts tests/ci-workflows/file-size-ratchet.test.ts tests/ci-workflows/repo-hygiene.test.ts tests/ci-workflows/structure-ssot.test.ts tests/ci-workflows/skill-ocx.test.ts tests/lab/core-lab-boundary.test.ts tests/lab/core-link-boundary.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` + - `bun run test:changed` + - `bun run privacy:scan` +3. **Real UI Screenshot Capture & `origin/pr-assets` Upload (`C-RENDER-GROUNDING-01`)**: + - Serve built `gui/dist` locally with deterministic `/api/link/*` fixtures and render in headless browser / GOAT browser at `1280x800`, capturing into `/tmp/ocx-child-ux-shots/`: + 1. `01-ready-to-plant-modal.png` (Add Child sheet after fingerprint check showing Ready-to-Plant card + CTA) + 2. `02-connected-children-badges.png` (Connected Children list showing Lightweight / Full mode badges & Home settings synced / Sync needed parity badges) + 3. `03-ephemeral-disconnect-purge.png` (Disconnect confirmation modal with fast-detach explanation and `--purge` checkbox checked) + - Visually inspect every screenshot with `view_image` (`C-RENDER-GROUNDING-01`) to verify crisp typography, spacing, contrast, and zero clipping/i18n leaks. + - Fetch `origin/pr-assets`, check out a detached temporary worktree in `mktemp -d`, place PNGs under `pr-ocx-child-ux/`, commit, push `HEAD:refs/heads/pr-assets`, record immutable ``, and remove the temporary worktree. Verify `git status` on `feat/ocx-child-plant-ux` has zero image files! +4. **Stacked Pull Request Publication & Watch (`DEV-STACK-03`)**: + - Check `gh pr view 8237 --json state,headRefName,headRefOid`: + - If `OPEN`: verify `git merge-base --is-ancestor HEAD`, push `feat/ocx-child-plant-ux`, and open stacked PR with `gh pr create --base feat/ocx-child-ephemeral-link --body-file ` including the `DEV-STACK-03` stack table, Summary, Verification, Checklist, and embedded `https://raw.githubusercontent.com/lidge-jun/opencodex//pr-ocx-child-ux/*.png` screenshots. + - If `MERGED`: rebase cleanly onto `origin/dev`, re-verify gates, push, and open PR with `--base dev`. + - Call `goat_pull_request_link`, watch via `goat_pull_request_watch` until required exact-head checks and automated reviews complete, address any valid findings, record `cxc loop pr-record --session 01a1b2d6-b6ca-76c3-8e6c-e00f7449b965 --pr `, run `cxc loop validate`, call `goat_pull_request_unwatch`, and leave PR unmerged. + +## 3. `wp4` P-Phase Continuity & Architect Reflection Amendments (`01a1b33c-63c3-7410-9ced-7be53251867e`) + +- **Continuity from `wp3` D**: Resumed from `wp3` D conclusion ("Next cycle wp4 syncs structure/ + docs-site/, captures #remote screenshots to origin/pr-assets, opens the stacked PR, watches CI/reviews, and records pr-record"). +- **Architect `01a1b33c-63c3-7410-9ced-7be53251867e` verdict**: `ALIGNED` on `W4-ARCH-01..W4-ARCH-03`. +- **Folded guards**: + 1. Edit `structure/remote-link.md` `## Ephemeral Rust Child (ocx-child)` in-place (replacing `409 child_restart_unsupported` with `409 child_port_busy`, documenting `409 child_external_provider_conflict`, `src/link/child-binary-source.ts`, `src/link/remote-plant.ts`, `--purge`, and `LinkRecord.childKind`, and keeping total line count comfortably below `manifest.json` cap). + 2. Include `bun run skill:surface:check` and `tests/ci-workflows/docs-link-targets.test.ts` in the `wp4` verification floor. + 3. Update `docs-site/src/content/docs/guides/remote-link.md` and `docs-site/src/content/docs/ko/guides/remote-link.md` (plus check locale pages for non-contradiction). + diff --git a/devlog/_plan/261107_telemetry_dashboard/000_plan.md b/devlog/_plan/261107_telemetry_dashboard/000_plan.md new file mode 100644 index 00000000000..688441c38da --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/000_plan.md @@ -0,0 +1,105 @@ +# Default-on (opt-out) telemetry and the opencodex.me usage dashboard + +## Objective and scope + +Add anonymous, default-on (opt-out) product telemetry to the opencodex proxy and a public, project-wide +analytics page at `https://opencodex.me/dashboard`. The proxy reports install/version/platform +pings, active time, request and token totals, and error counts; a collector stores daily +aggregates; the dashboard renders installs, active time, usage, errors and downloads. + +Delivery is a manual chain of stacked pull requests, one per work phase below, each based on the +previous layer and the bottom on `dev`. No deploy of the collector, no DNS change, no merge and no +release are part of this unit; those are maintainer actions after review. + +The visual and structural reference is [junhoyeo/tokscale](https://github.com/junhoyeo/tokscale) +(MIT): its contribution heatmap, KPI header, client/model breakdown tables, and the +validated aggregate submit API. Patterns are adapted, not vendored; credit lives in the dashboard +footer and in the PR descriptions. + +## Owner decisions (2026-11-07) + +| Id | Decision | +| --- | --- | +| hosting | Collector is a Cloudflare Worker with D1 at `telemetry.opencodex.me`. The repository ships its code, schema and tests; deployment is a maintainer step. | +| consent | Superseded by default-on. | +| update-popup | An install that updates and has no recorded decision sees the same consent popup once after the update. | +| default-on | Telemetry defaults to **on** (opt-out). The GUI first-run/post-update popup (checkbox pre-checked) and the first interactive `ocx start` (Y/n, default yes) let the user choose. `ocx init` and `ocx service install` ask the same question. An agent-driven start prints a notice and never answers for the user or records the notice. No payload is sent until a person has seen the disclosure. Advanced settings and `ocx telemetry disable` turn it off later. | +| scope | The dashboard is public and project-wide (aggregates only). No per-user profiles or leaderboards. | + +## Privacy contract (applies to every layer) + +- Effective state: env opt-out (`OCX_TELEMETRY=0` or `DO_NOT_TRACK=1`) → off; decision `declined` → off; `accepted` → on; undecided → on only after `noticeShownAt` is recorded, which only a person can cause (GUI popup seen, interactive TTY prompt answered). An agent-driven start prints a notice but records nothing, so an agent can never switch sending on. +- Identity is a random install id (UUID v4) generated on first send, never derived from + hardware, accounts, hostnames or paths. Disabling telemetry deletes it; re-enabling mints a new one. +- Payloads carry only closed-vocabulary values and counters: version, OS, arch, install channel, + provider *kind* (closed vendor roster), coarse model family, closed + error codes, and integer counts. Never request or response bodies, prompts, model output, API + keys, OAuth tokens, account ids or e-mails, custom base URLs, hostnames, file paths, or free-form + error text. +- The exact JSON the next send would contain is visible before consent ("What is sent") and via + the management API and `ocx telemetry status`. +- The collector stores no IP address and no raw install id: reports carry a per-day install hash, + rate limiting uses a salted hourly IP hash pruned after two hours, and only the first report per + install and day counts. +- No backfill: only UTC days strictly after the day of the disclosure or decision are sent, days + covered by an `OCX_TELEMETRY=0` / `DO_NOT_TRACK=1` opt-out are never sent (060 D2), and at most one + day is sent per scheduler tick. +- The published privacy statement (README and its seven translations, the docs-site code-signing + page in English and Turkish, `SPONSORS.md`) describes default-on telemetry and every way to turn it + off. It changes in layer 2, the first layer that can send, never later (060 D12). +- The collector URL is a constant; it is not configurable from `config.json`. +- Telemetry code stays off the core request path: `src/router.ts`, + `src/server/lifecycle.ts` and `src/server/responses/core.ts` must not import `src/telemetry/`. + Aggregates are computed from the existing usage log on a timer, never inline in a request. + +## Work phases (stack order) + +| Layer | Doc | Thesis | +| --- | --- | --- | +| 1 | this unit | Docs-only roadmap. | +| 2 | [010_client_core.md](010_client_core.md) | Config field, consent state, install id, aggregate builder, payload v1, sender, management API, `ocx telemetry`, `structure/telemetry.md`. | +| 2b | [060_review_round.md](060_review_round.md) D21 | Windows installer page (disclosure, policy link, opt-out checkbox) and the desktop app carrying an installer opt-out into the config. | +| 3 | [020_gui_consent.md](020_gui_consent.md) | First-run card, post-update popup, Advanced toggle, preview dialog, all locales. | +| 4 | [030_collector.md](030_collector.md) | Cloudflare Worker + D1: ingest validation, limits, daily rollups, public stats API. | +| 5 | [040_docs_privacy.md](040_docs_privacy.md) | Telemetry guide, sidebar, configuration reference. | +| 6 | [050_dashboard.md](050_dashboard.md) | Astro page `/dashboard`: heatmap, KPIs, breakdowns, errors, downloads. | + +## Verification floor per layer + +`bun run typecheck`; focused tests for the layer plus `bun run test:changed`; +`bun run structure:check`, `bun run privacy:scan`, file-size ratchet and test-layout guards for +any layer adding files; `bun run lint:gui` and `bun run build:gui` for layer 3; docs-site build +for layers 5 and 6; `bun run skill:surface:check` when a CLI capability is added. Each layer gets an +independent review before its PR is opened. + + +## Audit disposition (round 1) + +Independent audit (argon, read-only): FAIL with 9 blockers and 6 majors; all accepted and folded. +Agent-driven starts no longer record the notice; the notice and decision routes need a human session; +`ocx telemetry enable` and `ocx config set telemetry` refuse agent-driven use; no persisted endpoint; +no pre-consent backfill and one day per tick; an import-free shared contract module with closed rosters +that the collector imports; strict version regex; `OcxTelemetryConfig` in `src/types/telemetry.ts` +and no edits to `src/cli/index.ts` or `scripts/test-layout/layout.json` (ratchet headroom 0–4 lines); +`telemetry` added to the skill-surface roots; session-only route exemptions; the docs guide now lands +before the dashboard; collector typecheck test; a tick-driven scheduler that re-reads config and stops in +`stopProcessLoops`, with `OCX_TELEMETRY=0` in the test preload; explicit install-channel mapping; GUI +dialog sequencing via `onSettled`, notice only on dismiss, `readOnly`; unified `env` and kebab-case +causes; disjoint token buckets; `TelemetryStatsV1` with `dailyByProvider` and a 365-day heatmap; dedup +on `(day, install_day_hash)`; prompts in `ocx init` and `ocx service install`; sidebar, configuration +reference and both header menus. Goalplan note: work phase wp4 delivers 040 (docs) and wp5 delivers 050 +(dashboard). + + +## Audit disposition (round 2) + +Same reviewer: PASS, 0 blockers; all 9 blockers and 6 majors verified resolved. Two minors folded: the +collector `tsconfig.json` overrides `rootDir`/`declaration` (030), and the new capability objects in +`src/cli/capabilities-observe-system.ts` stay plain literals because `tests/helpers/cli-capability-data.ts` +rejects `/` tokens and host identifiers there (010). + +## PR review round 1 + +Reviewer feedback on the open stack is recorded, with owner decisions D1–D21, in +[060_review_round.md](060_review_round.md). Those decisions supersede conflicting text in 000–050, including +the public privacy statement change that must land with the first layer able to send. diff --git a/devlog/_plan/261107_telemetry_dashboard/010_client_core.md b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md new file mode 100644 index 00000000000..ac279c4db62 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/010_client_core.md @@ -0,0 +1,179 @@ +# 010 — Client telemetry core + +Stack layer 2. Everything the proxy needs to decide, build, preview and send a daily report, the +management API and CLI that later layers use, the shared wire contract, and the structure doc that +claims the new `src/` area. Anchors were read on `c61e957900`. + +## Shared wire contract (`src/telemetry/contract.ts`) + +An **import-free** module (same rule as `src/usage/telemetry-contract.ts`) holding the closed rosters +and the payload validator: `TELEMETRY_SCHEMA_VERSION = 1`, `PROVIDER_KINDS`, `MODEL_FAMILIES`, +`INSTALL_CHANNELS`, `RELEASE_CHANNELS`, `OS_VALUES`, `ARCH_VALUES`, the outcome classes and the failure +causes (re-declared and pinned equal to `REQUEST_OUTCOME_CLASSES` / `REQUEST_FAILURE_CAUSES` by a test), +and `validateTelemetryPayloadV1(input, now)`. The collector (030) imports this file directly, so the +client cannot emit a key or value the collector rejects and the two cannot drift. + +- `PROVIDER_KINDS` (vendor-level, closed): openai, chatgpt, anthropic, claude-subscription, google, + vertex, xai, deepseek, moonshot, qwen, zai, minimax, mistral, openrouter, groq, cerebras, together, + fireworks, cursor, kiro, copilot, devin, ollama, lmstudio, local-other, other. The client maps a + provider's registry preset to a kind; custom/unknown providers become `other`. User-chosen provider + keys, labels and base URLs are never read into the payload. +- `MODEL_FAMILIES` (closed prefix buckets): gpt-6, gpt-5, gpt-4, o-series, codex, claude-opus, + claude-sonnet, claude-haiku, gemini-3, gemini-2, grok, deepseek, qwen, kimi, glm, minimax, mistral, + llama, other. +- `version` must match `^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$` (no build metadata). + +## State model + +Type `OcxTelemetryConfig` lives in a new `src/types/telemetry.ts`; `src/types/config.ts` (1996 lines, +ratchet threshold 2000) gains exactly one import line and one field line, or an existing block moves +out first if the count says otherwise. Schema in `src/config/schema/config-schema.ts` as +`telemetryConfigSchema.optional().catch(undefined)`, strict live-write validation in +`src/config/diagnostics.ts`, load-degrade warning in `src/config/load-degrade.ts`, baseline adoption +`adoptPersistedTelemetry` in `src/config/live-reconcile.ts`. + +```ts +type OcxTelemetryConfig = { + decision?: "accepted" | "declined"; // absent = undecided + decidedAt?: string; // ISO + noticeShownAt?: string; // a human saw the disclosure (GUI or interactive TTY) +}; +``` + +There is **no persisted endpoint**: the collector URL is the constant +`https://telemetry.opencodex.me/v1/events` in `sender.ts` with a test-only injection seam. + +`resolveTelemetryStatus(config, env)` (`src/telemetry/consent.ts`): + +| Condition (first match) | `effective` | `reason` | +| --- | --- | --- | +| `OCX_TELEMETRY` ∈ {0,false,off,no} | false | `env-ocx-telemetry` | +| `DO_NOT_TRACK` ∈ {1,true,yes} | false | `env-do-not-track` | +| `decision === "declined"` | false | `declined` | +| `decision === "accepted"` | true | `accepted` | +| undecided and `noticeShownAt` set | true | `default-on` | +| undecided, no notice | false | `awaiting-notice` | + +## Who may change it (agent boundary) + +Telemetry is on by default, so the only thing that can switch sending on for an undecided install is +a human seeing the disclosure. Following `AGENTS_INSTALL.md` and `src/cli/star-prompt.ts`: + +- An agent-driven start (`isAgentDriven()`, non-TTY, or `OCX_SERVICE=1`) prints a one-line notice at + most once per version (`.telemetry-deferred` marker in the config dir) and writes **neither** + `noticeShownAt` nor `decision`. The install stays `awaiting-notice` until a human sees the GUI popup + or an interactive prompt. +- `PUT /api/telemetry` requires `hasHumanDashboardSession(ctx)`. There is no separate notice route: + only a decision records the notice (060 D13). +- `ocx telemetry enable` refuses with `agent_consent_required` (non-zero exit) when `isAgentDriven()`; + `ocx telemetry disable` is always allowed (opting out is never a consent risk). +- `src/cli/config-command.ts` `set`/`unset` refuse the `telemetry` key, and `import` drops a + `telemetry` block when agent-driven, pointing to `ocx telemetry`. +- `AGENTS_INSTALL.md` gains a short "Telemetry" entry in its user-consent list. + +## Identity + +`src/telemetry/install-id.ts` keeps `/telemetry-id.json` (`0o600`, exclusive no-follow +create, pattern of `src/quota-pool/identity.ts`), created on the first send and deleted when the +decision becomes `declined`. The raw id never leaves the machine; each report carries +`installDayHash = sha256(installId + ":" + day)` and `newInstall: true` only on the very first report +of a freshly minted id (the collector counts new installs per day, version and channel from it). + +## Aggregation (`src/telemetry/aggregate.ts`) + +Scans `usage.jsonl` with `scanUsageLedgerCooperatively` (`src/usage/ledger-scanner.ts`) for one +completed UTC day: + +- `activeMinutes`: distinct `floor(timestamp / 60000)` values (≤ 1440). +- `requests`: counts per outcome class via `classifyRequestOutcome` (`src/usage/request-outcome.ts`). +- `models`: per (`providerKind`, `modelFamily`) requests and **disjoint** token buckets — + `inputTokens` = uncached input (`normalizeCostTokens`, `src/usage/cost.ts`), `cacheReadTokens`, + `cacheWriteTokens`, `outputTokens` = output minus reasoning, `reasoningTokens`. The five sum to the + display total (`usageDisplayTotalTokens`); a test pins that identity. +- `errors`: counts keyed by failure cause (kebab-case, e.g. `rate-limit`). + +## Payload v1 + +```json +{ "schemaVersion": 1, "batchId": "", "day": "2026-11-06", "installDayHash": "<64 hex>", + "newInstall": false, + "env": { "version": "2.40.1", "releaseChannel": "latest", "os": "darwin", "arch": "arm64", + "installChannel": "npm-global" }, + "activeMinutes": 312, + "requests": { "completed": 840, "failed": 12, "incomplete": 3, "aborted": 5 }, + "models": [ { "providerKind": "anthropic", "modelFamily": "claude-opus", "requests": 400, + "inputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, + "outputTokens": 0, "reasoningTokens": 0 } ], + "errors": { "rate-limit": 7 } } +``` + +`installChannel` mapping in `src/telemetry/payload.ts`: `OCX_DESKTOP_SUPERVISED === "1"` → desktop; +container markers (`/.dockerenv`, `/run/.containerenv`) → docker; `detectInstallOwnership().installer` +npm → npm-global, pnpm → pnpm-global, bun → bun-global, mise → mise, standalone → standalone, +source → source; otherwise unknown. Every field is named explicitly in the builder and the result is +run through `validateTelemetryPayloadV1` before sending or previewing. + +## Scheduling and sending + +- `src/server/background-lifecycle.ts` `startProcessLoops` starts a dormant `.unref()` scheduler via + `void import("../telemetry/scheduler")…` unless an env opt-out is set; `stopProcessLoops` stops it + (handle kept in background-lifecycle), and `registerOptionalShutdownHook("telemetry", stop)` covers + drain. `src/server/index.ts` and `src/cli/index.ts` (1999 lines) are not touched. +- Each tick (startup jitter, then every 6 h) re-reads config passively from disk and re-evaluates + `resolveTelemetryStatus`, so CLI and GUI changes take effect without a restart. The management + routes also call `adoptPersistedTelemetry` and nudge the scheduler. +- It sends only completed days strictly after the UTC day of `decidedAt ?? noticeShownAt` (060 D1; no pre-consent + backfill), at most **one day per tick**, oldest first, at most the last 7 days; 10 s + `signalWithTimeout`, no in-tick retry. Sent days live in `/telemetry-state.json`. +- `tests/preload.ts` sets `OCX_TELEMETRY=0` so no test process ever sends. + +## Management API + +Thin `src/server/management/telemetry-routes.ts` (seen by `management-route-registry.test.ts`) +delegating to `src/telemetry/routes.ts`, lazily namespaced from `src/server/management-api.ts` and +declared in `route-registry.ts`: + +- `GET /api/telemetry` → effective/reason/decision/env override, install channel, last sent day, + `preview` (the exact payload for the latest completed day; hash shown as a placeholder when no id + exists). +- `PUT /api/telemetry` `{ decision }` — human session only; `exempt: { reason: "session-only" }`. + +## CLI + +`src/cli/telemetry.ts`: `ocx telemetry status [--json]` (route `GET /api/telemetry`, falls back to +local status when the proxy is down), `enable` and `disable` (local config mutation, `routes: []`, +`mutates: true`). Capability objects stay plain string/array/object literals (the lexical guard in `tests/helpers/cli-capability-data.ts` rejects `/` tokens and host identifiers). Registered in `capabilities-observe-system.ts`, `registry.ts`, `dispatch.ts`, +`help.ts`; `telemetry` is added to the `observe-system` roots in +`scripts/generate-ocx-skill-surface.ts` and `tests/ci-workflows/skill-ocx-generated.test.ts`, then +`bun run skill:surface`. + +Interactive prompt `Share anonymous usage statistics? [Y/n]` (default yes; Enter = yes; records +`decision` and `noticeShownAt`) shown once when undecided, from: `maybeShowStarPrompt()`'s module on +interactive `ocx start` (no new line in `src/cli/index.ts`), `ocx service install` next to its star +prompt (`src/service/cli.ts`), and the end of `ocx init` (`src/cli/init.ts`). + +## Ownership + +`structure/telemetry.md` registered in `structure/manifest.json` claiming `src/telemetry/`, +`src/types/telemetry.ts`, `src/server/management/telemetry-routes.ts` and `src/cli/telemetry.ts`, +naming only paths that exist in this layer; `bun run structure:index`. The four 600-line structure +docs are not extended. + +## Verification + +- `tests/usage/usage-telemetry-client.test.ts`: status matrix, env precedence, id file mode and + deletion, aggregate bucketing and the disjoint-token identity on a fixture ledger, payload validator + rejects extra keys/unknown roster values/semver metadata, roster parity with the usage contract, no + pre-consent days, one day per tick, sender timeout, nothing sent when not effective. +- `tests/usage/usage-telemetry-core-boundary.test.ts`: `router.ts`, `lifecycle.ts`, + `responses/core.ts`, `management-api.ts` have no load-time path to `src/telemetry/`. +- `tests/server/server-telemetry-routes.test.ts`: GET preview; PUT and notice refused with admin token + and remembered-admin; accepted with a human session; notice idempotent. +- `tests/cli/cli-telemetry.test.ts`: status/enable/disable, enable refused when agent-driven, config + set refusal, agent-driven start writes no notice, prompt default yes. +- New tests registered in a domain shard (`layout-usage.json` etc.; `layout.json` is at 1999 lines) + and `tests/fixtures/test-layout-expected.json`. +- `bun run typecheck`, focused files, `bun run test:changed`, `bun run structure:check`, + `bun run skill:surface:check`, `bun run privacy:scan`, file-size and layout guards, + `tests/cli/cli-capabilities.test.ts`, `tests/server/management-route-registry.test.ts`. + diff --git a/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md b/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md new file mode 100644 index 00000000000..0a11470934f --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/020_gui_consent.md @@ -0,0 +1,57 @@ +# 020 — GUI consent popup, post-update popup and Advanced toggle + +Stack layer 3. Depends on 010 (`GET /api/telemetry`, `PUT /api/telemetry`). There is no notice-only +endpoint (060 D13). + +## Behaviour + +- `TelemetryConsentCard` is a native `` mounted beside + `` in `gui/src/App.tsx`. It is enabled only when + `targetsSettled && !targets.connected && sharedSessionReady && starOnboardingSettled`. + `DesktopStarOnboarding` gains an `onSettled` callback (fired when it closes or decides it will not + show), so the two dialogs never open together. +- It opens when the status reports an undecided install with no env override and the proxy says + `reason === "awaiting-notice"` or `"default-on"` without a decision. That covers first run and + installs that update into this version (they have no decision yet): the post-update popup. +- The checkbox is **pre-checked**. Continue sends `PUT /api/telemetry { decision: checked ? "accepted" : + "declined" }` (this also records the notice server-side). Escape and the backdrop record nothing, + and the install stays undecided for the next start (060 D13). Opening the dialog alone records + nothing, so an unread or automated page load never enables sending. Focus is moved to the heading + right after `showModal()`, so a stray Enter cannot press Continue. +- The card lists what is sent and what is never sent and has a "What is sent" disclosure with the + exact preview JSON from the status endpoint. +- `TelemetrySettingsPanel` renders in Proxy details (`SettingsSection id="proxy-telemetry"` in + `dashboard-overview-panels.tsx`, id appended to `PROXY_SECTION_IDS`) with `Switch` from `ui.tsx`, + `readOnly` honoured for Remote Link children, and the preview dialog. With `OCX_TELEMETRY=0` or + `DO_NOT_TRACK=1` the switch is disabled and a `Notice tone="warn"` names the variable. + `AdvancedHub.tsx` gets `telemetry`/`privacy` search words on the proxy entry. +- The panel and card fetch on their own with `createBoundedFetch(15_000)`, outside + `useDashboardData` (dashboard-contracts poll-wave rule). + +## Files + +| File | Change | +| --- | --- | +| `gui/src/telemetry-api.ts` | DTO types and fetch helpers (non-component, oxlint `only-export-components`). | +| `gui/src/components/telemetry-consent-card.tsx` | First-run / post-update dialog. | +| `gui/src/components/TelemetrySettingsPanel.tsx` | Toggle row and preview dialog. | +| `gui/src/components/desktop-star-onboarding.tsx` | `onSettled` prop. | +| `gui/src/App.tsx` | Mount the card, track `starOnboardingSettled`. | +| `gui/src/pages/dashboard-section-ids.ts`, `dashboard-overview-panels.tsx`, `AdvancedHub.tsx` | Section, panel, search words. | +| `gui/src/i18n/v2-pages/proxy-copy.ts` | All `proxyV2.telemetry*` rows in the 11-column table (en, ko, de, fr, zh, zh-TW, ru, ja, tr, vi, pt). | + +No CSS is added to `gui/src/styles.css` (ratchet cap); reuse `.modal-overlay`/`.modal-card` and v2 +utilities. + +## Verification + +- `gui/tests/telemetry-consent-card.test.tsx`: waits for star onboarding; shows only when undecided + and no env override; checkbox pre-checked; nothing recorded on open; Continue sends accepted / + declined; Escape records nothing; hidden once decided. +- `gui/tests/telemetry-settings-panel.test.tsx`: switch mirrors effective state, toggles via PUT, + disabled with env notice and in read-only mode, preview dialog shows the JSON. +- `gui/tests/desktop-star-onboarding.test.tsx` extended for `onSettled`. +- `cd gui && bun test tests && bun run lint && bun run lint:i18n && bun run build`; root + `bun run typecheck`. +- Screenshot of the card and panel for the PR description (uploaded via `pr-assets`, never committed). + diff --git a/devlog/_plan/261107_telemetry_dashboard/030_collector.md b/devlog/_plan/261107_telemetry_dashboard/030_collector.md new file mode 100644 index 00000000000..4e65518c686 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/030_collector.md @@ -0,0 +1,76 @@ +# 030 — Telemetry collector (Cloudflare Worker + D1) + +Stack layer 4. Imports the wire contract `src/telemetry/contract.ts` from 010 directly. + +## Layout (dependency-free) + +| File | Role | +| --- | --- | +| `services/telemetry-collector/wrangler.toml` | Worker name, route `telemetry.opencodex.me/*`, D1 binding `DB` with placeholder id `00000000-0000-0000-0000-000000000000`, cron trigger for pruning. Real ids are set by a maintainer at deploy time. | +| `services/telemetry-collector/schema.sql` | D1 tables (below). | +| `services/telemetry-collector/tsconfig.json` | Extends root settings with `rootDir: "../.."`, `noEmit: true`, `declaration: false` (as in `scripts/test-layout/tsconfig.verify.json`) so importing `../../src/telemetry/contract.ts` does not hit TS6059. | +| `services/telemetry-collector/src/types.ts` | Minimal local `D1Database`/`D1PreparedStatement`/`ExecutionContext` interfaces, `CollectorEnv`, `TelemetryStatsV1`. | +| `services/telemetry-collector/src/ingest.ts` | `POST /v1/events`. | +| `services/telemetry-collector/src/stats.ts` | `GET /v1/stats?range=7d|30d|90d|365d`. | +| `services/telemetry-collector/src/prune.ts` | Scheduled pruning. | +| `services/telemetry-collector/src/index.ts` | `fetch`/`scheduled` handlers, CORS, 404/405. | +| `services/telemetry-collector/README.md` | Deploy: `bunx wrangler d1 create`, `d1 execute --file schema.sql`, secrets (`RATE_LIMIT_SALT`), `wrangler deploy`. | + +No root `package.json`/`bun.lock` change. The root typecheck only covers `src/`; this layer adds a +test that typechecks the collector project with the repository's own TypeScript toolchain (command +chosen and proven in B, since `node_modules/.bin/tsc` is absent and `bun check` is the root checker). + +## Storage + +- `daily_installs(day, install_day_hash, version, release_channel, os, arch, install_channel, + new_install, active_minutes, PRIMARY KEY(day, install_day_hash))` — pruned after 400 days. +- `daily_totals(day, reports, new_installs, active_minutes, completed, failed, incomplete, aborted, + input, cache_read, cache_write, output, reasoning)`. +- `daily_models(day, provider_kind, model_family, installs, requests, input, cache_read, cache_write, output, + reasoning)` (`installs` defaults to 0 for migration safety). +- `daily_errors(day, cause, installs, count)` (`installs` defaults to 0 for migration safety). +- `rate_limits(bucket_hour, ip_hash, hits)` — pruned after 2 h. + +**Dedup:** ingest executes conditional rollup statements (`WHERE NOT EXISTS (SELECT 1 FROM daily_installs WHERE day = ? AND install_day_hash = ?)`) followed by `INSERT OR IGNORE INTO daily_installs` as the final statement of a single atomic D1 `batch`. A re-sent day — new `batchId` or not — changes nothing, and a failed batch rolls back completely so retries succeed. Raw install ids and IPs are never stored; `RATE_LIMIT_SALT` is mandatory (503 when missing) and `ip_hash = sha256(RATE_LIMIT_SALT + "\0" + hour + "\0" + ip)`. + +## Validation and limits + +`validateTelemetryPayloadV1` from the contract (closed keys at every level, closed rosters for +provider kind, model family, channels, os, arch, failure causes; calendar-checked `day` in +[today − 7, today + 1]; strict version regex ≤ 32 chars; bounded per-row and per-report integers clamped to `Number.MAX_SAFE_INTEGER`; ≤ 64 model rows). Streaming body ≤ 16 KiB (413), JSON only (400), atomic 120 reports/hour per IP hash (429 with `Retry-After`), 503 if `RATE_LIMIT_SALT` is unset. Any roster or contract change must be deployed to the collector before a client release emits it. + +## Stats API (`TelemetryStatsV1`, consumed by 050) + +```ts +type TelemetryStatsV1 = { + schemaVersion: 1; generatedAt: string; range: "7d" | "30d" | "90d" | "365d"; + totals: { activeInstalls: number; avgDailyActiveInstalls: number; newInstalls: number; activeMinutes: number; requests: number; + tokens: { input: number; cacheRead: number; cacheWrite: number; output: number; reasoning: number } }; + daily: Array<{ day: string; activeInstalls: number; newInstalls: number; activeMinutes: number; + requests: number; failed: number; tokens: number }>; + dailyByProvider: Array<{ day: string; providerKind: string; requests: number; tokens: number }>; + heatmap: Array<{ day: string; activeInstalls: number }>; // always the last 365 days + providers: Array<{ providerKind: string; requests: number; tokens: number }>; + models: Array<{ modelFamily: string; requests: number; tokens: number }>; + versions: Array<{ version: string; installs: number; newInstalls: number }>; + platforms: Array<{ os: string; arch: string; installs: number }>; + channels: Array<{ installChannel: string; installs: number; newInstalls: number }>; + outcomes: { completed: number; failed: number; incomplete: number; aborted: number }; + errors: Array<{ cause: string; count: number }>; +}; +``` + +Served with `Cache-Control: public, max-age=300` and CORS for `https://opencodex.me` and +`http://localhost:4321`. + +## Verification + +- `tests/usage/usage-telemetry-collector-worker.test.ts`: `bun:sqlite` in-memory D1 shim loading + `schema.sql` via `repoPath`; ingest accepts a payload built by the 010 builder, rejects unknown keys + and roster values, dedups re-sent days, rate-limits with 429, sets CORS, returns every stats field + for each range with a full 365-day heatmap, never stores a raw id or IP; pruning. +- `tests/usage/usage-telemetry-collector-typecheck.test.ts`: the collector project typechecks. +- Registered in `scripts/test-layout/layout-usage.json` and `tests/fixtures/test-layout-expected.json`; + add `ci:full` if the import graph misses them. +- `bun run typecheck`, `bun run privacy:scan` (placeholder ids only), file-size and layout guards. + diff --git a/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md new file mode 100644 index 00000000000..7d944d29330 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/040_docs_privacy.md @@ -0,0 +1,50 @@ +# 040 — Telemetry guide and configuration reference + +Stack layer 5. Lands before the dashboard (050) because the dashboard links to this guide and the +docs build rejects links to missing pages. + +## Privacy statement (lands in layer 2, not here) + +The project's published privacy statement must stop saying that nothing is sent before the first +layer that can send merges, so these files change in layer 2 (060 D12) and layer 5 only links them to +the guide: + +- `README.md` (Code signing policy → Privacy) and the same line in `readme/README.fr.md`, + `README.ja.md`, `README.ko.md`, `README.ru.md`, `README.tr.md`, `README.zh-CN.md` and + `README.zh-TW.md`, with `readme/i18n-manifest.json` refreshed. +- `docs-site/src/content/docs/reference/code-signing.md` and its Turkish copy + `docs-site/src/content/docs/tr/reference/code-signing.md` (the Privacy section, including the + "carry none of your … usage data" sentence). +- `SPONSORS.md` ("opencodex does not collect any" telemetry). + +The statement replaces SignPath's "will not transfer any information …" sentence, which SignPath +allows only as an alternative to linking a privacy policy. How the stack meets SignPath's +install-time conditions is recorded in 060 ("SignPath conditions", D21); layer 2b adds the Windows +installer option to these files. + +## Public docs + +- `docs-site/src/content/docs/guides/telemetry.md` (English source): what is collected, what is never + collected, default-on with "nothing is sent until a person has seen the notice", agent-driven starts + never enable it, how to turn it off (GUI popup, Proxy details toggle, `ocx telemetry disable`, + `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1`), how to see the exact payload (`ocx telemetry status --json`, + "What is sent"), storage and retention (daily aggregates, install hash rotates daily, no IPs), and + the collector source location. No link to `/dashboard` yet (050 adds it). +- `docs-site/astro.config.mjs`: add `guides/telemetry` to the Guides sidebar. +- `docs-site/src/content/docs/reference/configuration/server.md`: `telemetry` config block and the + two environment variables. +- `guides/web-dashboard.md`: one line pointing to the Proxy details telemetry toggle. +- Translated locales follow the docs-site locale rule (translated or absent with English fallback); + nothing may contradict the English page. + +## Maintainer docs + +`structure/telemetry.md` (from 010) gains the collector location (`services/telemetry-collector/`, +now present) and the guide path. + +## Verification + +docs-site build (`cd docs-site && bun install --frozen-lockfile && bun run build`), +`tests/ci-workflows/docs-link-targets.test.ts` and the other `docs-*` claim guards, +`bun run structure:check`, `bun run privacy:scan`. + diff --git a/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md b/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md new file mode 100644 index 00000000000..41609671e36 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/050_dashboard.md @@ -0,0 +1,43 @@ +# 050 — opencodex.me/dashboard + +Stack layer 6. Reads `GET https://telemetry.opencodex.me/v1/stats` (`TelemetryStatsV1`, 030) from the browser and links the telemetry guide from 040. + +## Page + +`docs-site/src/content/docs/dashboard.mdx` with `template: splash`, mounting +``. A content page (not `src/pages/`) gets Starlight's locale fallback routes, +so the header's language links (`/ko/dashboard/` ...) resolve and `internal-links.mjs` and +`tests/ci-workflows/docs-link-targets.test.ts` pass unchanged. + +## Sections (adapted from tokscale, MIT, credited in the footer) + +1. Header: title, range pills (7d / 30d / 90d / 365d), last-updated time, privacy callout linking the + telemetry guide and the opt-out variables. +2. KPI strip (tokscale `ProfileOverview`): active installs, requests, tokens, active hours — + `tabular-nums`, compact K/M/B with exact values in `title`. +3. Token mix bar (tokscale `TokenBreakdown`): input / cache read / cache write / output / reasoning — disjoint buckets from the payload contract, so the segments sum to the total. +4. Activity: a 52-week heatmap of daily active installs from the always-365-day `heatmap` series (tokscale `ProfileContributionGraph`, 5 grades) + and an ECharts stacked daily chart from `dailyByProvider` (tokens or requests by provider kind). ECharts is already a + docs-site dependency (`FrontierBoards.astro`) and re-themes on `data-theme` changes. +5. Provider-kind and model-family share tables (tokscale `ProfileModels` colour dots). +6. Platform, version and install-channel distribution, new installs per version/channel; reliability (outcome ratio and top error codes). +7. Downloads: npm weekly/total downloads (`api.npmjs.org/downloads`) and GitHub release asset download + counts (`api.github.com/repos///releases`), fetched client-side with graceful fallback. + +Loading, empty ("collector not deployed yet") and error states are explicit; no layout jumps. + +## Files + +`docs-site/src/components/TelemetryDashboard.astro` (shell + client script), +`docs-site/src/components/telemetry-dashboard/*.ts` (pure formatting / series helpers testable from +`tests/`), `docs-site/src/data/telemetry-dashboard-i18n.ts` (copy for the 8 site locales), and the localized `/dashboard/` link in both the desktop nav and the splash +mobile menu of `docs-site/src/components/Header.astro`, plus a link from `guides/telemetry.md`. + +## Verification + +- `tests/ci-workflows/docs-telemetry-dashboard.test.ts`: the route exists, helpers format and bucket a + fixture stats response correctly, empty/error states render, no hard-coded secrets. +- `cd docs-site && bun install --frozen-lockfile && bun run build`. +- Browser check of the built page against a fixture stats JSON (screenshot for the PR). + + diff --git a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md new file mode 100644 index 00000000000..c00c57a0230 --- /dev/null +++ b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md @@ -0,0 +1,127 @@ +# 060 — PR review round 1 + +Review feedback on the stack (#8293, #8300, #8303, #8304, #8305, #8308) from Ingwannu, CodeRabbit and the +Codex reviewer. These decisions supersede any conflicting text in 000–050. Each lands on the layer that +owns the code, and each layer's PR description records it. + +## Why the privacy statement changes (blocking) + +`README.md` (and its seven translations under `readme/`) and `docs-site/src/content/docs/reference/code-signing.md` +carry the SignPath Foundation sentence "This program will not transfer any information to other networked +systems unless specifically requested…". With default-on telemetry that sentence would be false. SignPath's +conditions (https://signpath.org/terms.html) allow software that sends data to systems the user did not +specify only if the behaviour is described in a privacy policy, shown during installation, and can be +disabled at installation. The replacement statement (D12) says exactly that, lands on layer 2 (the first +layer that can send), and layer 5 links it to the telemetry guide. + +## SignPath conditions + +SignPath Foundation's conditions (https://signpath.org/terms, read 2026-11-08), "Respect user privacy and +security": "Software that collects user data and transfers it to systems not specified by the user must +a) describe this behavior in a privacy policy, b) display this policy during installation and c) include +installation options to disable these functions." Its website condition allows either a privacy-policy link +or the "will not transfer" sentence. + +(a) is met by the D12 statement. (b) and (c) are not clearly met. SignPath signs the standalone `ocx.exe`, +which has no installer, and the MSI, whose installer shows no policy and has no disable option. The CLI +prompt and the dashboard popup appear on first run, after installation. The release workflow still uses the +`test-signing` policy, so no production-signed build exists yet. Round 1 treated first-run disclosure as +"during installation"; that reading is not confirmed. + +Owner decision (2026-11-08), D21: telemetry stays default-on and the stack satisfies all three: + +- (a) The D12 privacy statement (README and translations, code-signing page in English and Turkish, + `SPONSORS.md`) and the layer 5 guide describe exactly what is sent and how to opt out. +- (b, c) MSI: a new layer between 2 and 3 adds an installer page generated from + `src/telemetry/disclosure.ts`, the same text the CLI prompt prints. It links the policy and has a + "Send anonymous usage statistics" checkbox, checked by default. Unchecked, the choice is recorded in + HKCU and the desktop app persists `telemetry.decision = "declined"` into the user's config before it + resolves, attaches to or starts any proxy. Tests pin the dialog text source and the persisted opt-out. +- (b, c) Standalone `ocx.exe` and npm installs have no installer: nothing is sent before + `noticeShownAt`, and the notice (the CLI prompt or the GUI popup) shows the policy summary and offers + disabling at that moment. A test proves zero sends before `noticeShownAt`. + +Layers that can send (2 and above) merge only after the installer layer and the zero-send test are in +the stack and have passed their reviews and CI. + +## Decisions + +D1 Consent-day cutoff: only UTC days strictly AFTER the UTC day of (decidedAt ?? noticeShownAt) are ever sent. +D2 Env opt-out: whenever OCX_TELEMETRY or DO_NOT_TRACK opt-out is observed (proxy startup in background-lifecycle even though the scheduler is not started, and on every tick), persist ineligibleThroughDay = today's UTC day and an envOptOutActiveSince marker in telemetry-state.json (atomic no-follow write) and delete the install id; only days strictly after ineligibleThroughDay are sent. A proxy started under the opt-out reads the env once and runs no ticks, so the marker covers the whole time it runs: while it is set nothing is eligible, and the period closes (ineligible through the closing day, marker cleared) on that proxy's clean stop or on the first tick without the opt-out. Tests: consented user runs with OCX_TELEMETRY=0 for days, removes it, those days are never sent and a fresh id is minted; a proxy that runs opted out from Monday to Thursday, ended by a crash and by a clean stop, never lets those days be sent (failing-first in layer 2). +D3 Version regex everywhere (contract): ^\d+\.\d+\.\d+(-preview\.\d{8}(\.\d+)?)?$ ; test vectors 2.40.1, 2.6.15-preview.20260701, 2.6.15-preview.20260701.1, 2.7.9-preview.20260712.2; reject build metadata. +D4 ocx telemetry enable: requires an interactive human terminal (isatty stdin AND stdout, not OCX_SERVICE, not isAgentDriven); prints the disclosure block, then asks a confirm (default yes) and records accepted; otherwise exits non-zero with agent_consent_required / tty_required pointing to the dashboard. disable always allowed. +D5 Disclosure block printed before every CLI Y/n prompt (ocx start/init/service install and enable): what is sent, what is never sent, that aggregates are public at opencodex.me/dashboard, how to see the exact payload (ocx telemetry status --json), how to turn it off (ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1, Proxy details toggle). A CLI test pins the text. +D6 ocx config import: for EVERY caller, preserve the persisted telemetry block unchanged (ignore imported telemetry). Tests: undecided and declined installs. +D7 Re-read persisted consent and env immediately before transmission; if not effective, abort without sending. Ticks carry an AbortSignal cancelled by stop and by consent changes (nudge). +D8 Aggregation accumulates counters inside onEntry for the target day only; never retains ledger rows. +D9 CLI enable/disable: if mutatePersistedConfig returns unavailable (or throws), exit non-zero with an actionable error, print no success line, and do not delete the id unless the decline persisted. +D10 GET /api/telemetry preview uses the same day the scheduler would send next (oldest eligible unsent day). If none is eligible, preview the latest completed day and return previewEligible:false (field added); never imply it will be sent. +D11 Client honors HTTP 429 Retry-After (and 503): persist retryAfterUntil in telemetry-state.json, skip ticks until then; spreads sends with jitter. +D12 Privacy statement (README.md, all 7 readme/README.*.md, docs-site/src/content/docs/reference/code-signing.md and its tr copy, SPONSORS.md): replace the SignPath "will not transfer any information..." sentence with an accurate statement: opencodex sends one anonymous daily usage summary (version, platform, counts; never prompts, outputs, keys, accounts, hosts or paths) to telemetry.opencodex.me only after the person installing or operating it has seen the notice (dashboard popup or interactive CLI prompt) and has not declined; it can be turned off at install time in that prompt and anytime with ocx telemetry disable, OCX_TELEMETRY=0, DO_NOT_TRACK=1 or Proxy details; project-wide aggregates are public; otherwise data goes only where the user configures. Keep the README Privacy line in English in every translation (as today) unless readme/i18n-manifest.json rules require otherwise. code-signing.md: also fix the "carry none of your ... usage data" claim. +D13 GUI popup dismissal (Escape/backdrop/close) records NOTHING; only Continue (PUT /api/telemetry) records decision and notice; the popup returns on the next app start while undecided. Remove POST /api/telemetry/notice entirely (server route, registry entry, capability exemptions, GUI call, tests, docs). +D14 Stats semantics: activeInstalls in totals and installs in breakdowns are INSTALL-DAYS (sum of daily reporting installs); add totals.avgDailyActiveInstalls (install-days / days in range, rounded to 1 decimal). Dashboard shows "Avg daily active installs" as the KPI with install-days in the tooltip. +D15 Small-cohort protection (collector; revised after the round-2 review of #8304): every breakdown (providers, models, versions, platforms, channels, errors, dailyByProvider) publishes a value only for (day, key) pairs reported by at least 10 DISTINCT installs on that day, and applies the threshold before summing days into the range. One install therefore cannot pass it alone by reporting on many days or by spreading usage over many provider/family rows. Distinct per-day counts: daily_installs (one row per install-day) for version/platform/channel; daily_errors.installs (+1 per report per cause); daily_provider_installs and daily_family_installs (+1 per report per provider and per model family, however many daily_models rows the report carries). Single-valued dimensions (version, platform, install channel) fold a day's sub-threshold rows into that day's "other", published only if it reaches 10 on that day; the versions list keeps 49 named rows and folds the tail into "other". Multi-valued dimensions (providers, model families, error causes) drop sub-threshold rows instead of folding them, because a bucket of several such rows can be one install counted several times; a native "other" key needs 10 distinct installs like any key. totals/daily/heatmap unchanged. Document the residual subtraction risk. +D16 Rate limit: 120 reports/hour per salted hourly hash of the client key (shared IP / CGNAT capacity); 429 with Retry-After. The key comes only from cf-connecting-ip: IPv4 per address, IPv4-mapped IPv6 as its IPv4 address, other IPv6 collapsed to its /64 prefix (one subscriber usually holds a whole /64). x-forwarded-for is never consulted; a request without a valid cf-connecting-ip shares one fixed bucket. +D17 Dashboard labels all figures "self-reported by opencodex installs"; docs note reports are unauthenticated and can be forged within rate limits. +D18 Collector-first rollout: any roster/contract change must be deployed to the collector before a client release emits it (README of collector + structure doc). +D19 Guide discloses the salted hourly IP hash for rate limiting (pruned after 2 hours). +D20 Dashboard: GitHub release download totals follow pagination (per_page=100, follow Link rel=next up to 10 pages, mark "partial" if capped); applying an empty snapshot clears the heatmap. + + +## Finding → decision map + +| Finding | Source | Decision | Layer | +| --- | --- | --- | --- | +| Published privacy statement becomes false | Ingwannu (blocking) | D12 | 2, 5 | +| Env opt-out days are backfilled | Ingwannu (blocking) | D2 | 2 | +| Consent day usage before the notice | Ingwannu | D1 | 2 | +| Preview tags rejected by the version regex | Ingwannu | D3 | 2 (contract) | +| activeInstalls over a range is a sum | Ingwannu | D14 | 4, 6 | +| CLI prompt is not a disclosure | Ingwannu | D5 | 2 | +| Opt-in wording | CodeRabbit | title/objective now say default-on (opt-out) | 1 | +| Roster changes break older collectors | CodeRabbit | D18 | 4, 5 | +| `ocx telemetry enable` without a TTY | CodeRabbit | D4 | 2 | +| Imported consent can backdate sending | CodeRabbit | D6 | 2 | +| Identity kept under an env opt-out | CodeRabbit | D2 | 2 | +| Preview day differs from the next send | CodeRabbit | D10 | 2, 3 | +| Automated dismissal records the notice | CodeRabbit | D13 | 2, 3 | +| Atomic dedup and rollup | CodeRabbit | already in #8304 (single conditional batch) | 4 | +| Forged reports | CodeRabbit | D17 (labelled self-reported; rate limit) | 4, 5, 6 | +| More than 64 model rows | CodeRabbit | already folded into `other`/`other` in #8300 | 2 | +| Shared-IP capacity and Retry-After | CodeRabbit | D11, D16 | 2, 4 | +| Small-cohort exposure | CodeRabbit | D15 | 4, 6 | +| IP-hash disclosure | CodeRabbit | D19 | 5 | +| Consent re-check before sending | Codex P1 | D7 | 2 | +| Aggregation keeps the whole ledger | Codex P2 | D8 | 2 | +| CLI reports unpersisted changes | Codex P2 | D9 | 2 | +| Public docs for the controls | Codex P1 (#8300, #8303) | already in #8305 | 5 | +| Release pagination, heatmap reset | Codex P2 (#8308) | D20 | 6 | + +## Residual risks + +- Reports are unauthenticated. Rate limits and the self-reported label bound, but do not prevent, forged + figures. +- The per-day distinct-install threshold keeps one install's numbers out of every breakdown row, but + totals, daily and heatmap stay unfiltered, so the combined value of suppressed rows can still be + approximated by subtracting published rows from published totals. +- The rate limit keys IPv6 on the /64, so a client holding a larger prefix (a /48 has 65,536 /64s) + gets one budget per /64; requests without cf-connecting-ip all share one budget. +- Installs without an installer (the standalone `ocx.exe`, npm) meet SignPath's install-time conditions + only through the first-run notice (CLI prompt or dashboard popup); D21 accepts that reading. + +## Delivery (round 1) + +| Layer | Fix commit | Merged upward as | +| --- | --- | --- | +| 1 plan | `3e4597d1ba` | — | +| 2 client core | `9e0284d392` | `64ac26aa7d` (layer 1 merged in) | +| 3 GUI | `5b6092ca14` | `ea8175f2b7` | +| 4 collector | `057c57e8be` | `54019606ff` | +| 5 docs | `d8fc29c2c1` | `4ebb3c1499` | +| 6 dashboard | `ea78acfa0f` | `bed30cf2b7` | + +The layer 5 merge conflicted where layers 2 and 5 both rewrote the privacy statement (README, seven +translations, `readme/i18n-manifest.json`, `code-signing.md`, Turkish `code-signing.md`) and +`structure/telemetry.md`. Layer 5's text was kept: it carries the same D12 statement plus the guide link, +and its structure doc is a superset of layer 2's. Layer 2's README test passes against it. Branches move by +merge commits, never by force-push. diff --git a/devlog/_plan/261108_dev_single_branch_release/000_plan.md b/devlog/_plan/261108_dev_single_branch_release/000_plan.md new file mode 100644 index 00000000000..01a6c38f117 --- /dev/null +++ b/devlog/_plan/261108_dev_single_branch_release/000_plan.md @@ -0,0 +1,75 @@ +# Single-branch releases from green dev commits + +**Owner scope clarification, 2026-11-08:** retain the existing desktop signing, +notarization, MSI and updater flows for a reviewed commit reachable from `dev`. +Do not redesign their existing credential boundaries in this PR. Regressions +introduced by selecting a source or exposing secrets to unreviewed PR code remain +blockers. Authenticate the current npm channel floors and refuse ambiguous newer +evidence; do not require old legacy tags to match old npm `gitHead` values. Earlier +signing-redesign proposals below are historical and superseded by this decision. + +Release preview and stable artifacts from a tested `dev` commit without moving a release branch or committing a version bump. Maintainers choose a channel and, optionally, an exact source SHA; the release workflow uses the current reviewed workflow revision to build that source, stamps the selected version in its temporary checkouts, and publishes a tag at the source commit. This removes release freezes while retaining explicit cross-platform, service, ordering, and publication checks. + +**Work shape:** satisfy the release contract in one integrated change. **Trigger:** owner request on 2026-11-08. **Goal:** green-commit publication without a promotion PR. **Non-goals:** production publication, settings changes, or branch deletion. **Verifier:** focused release/CI tests, layout, ratchet and Lab guards, typecheck, structure and privacy gates, independent technical and security review, exact-head hosted CI, and an actual Actions dry run. **Stop:** deliver a reviewed PR and dry-run evidence; merge only after the owner's current no-regression gates pass and human maintainer change requests are resolved. Automated bot reviews are not merge gates. **Record:** this unit and the PR Verification section. **Terminal outcomes:** reviewed and validated change; or documented failing/missing evidence and a draft PR. **Escalation:** irreconcilable historical source lineage, permissions or cutover settings require a maintainer decision; never manufacture an attestation. + +## Evidence and decisions + +- `ci.yml:70` has manual lanes; `ci.yml:1760` checks the requested job graph. Existing run titles do not identify the lane: live `gh run list` returned `Cross-platform CI` for every lane. Scheduled runs resolve a separate checkout SHA (`ci.yml:608`), so they are not green evidence. +- `release.yml:39` serializes releases; its publish step checks push-event CI (`release.yml:1043`), cumulative service paths (`release.yml:1102`), and a dev version pre-move (`release.yml:1142`). `scripts/ci/release-preflight.sh` repeats the old branch and version gates. `scripts/release.ts:601` currently commits and pushes version changes. `scripts/release-version-sources.ts` already supplies `sync` and `check` for all four sources. +- Local GOAT `docs/internals/release-channels.md` describes selecting a green dispatch, refusing an older/divergent published source, stamping four sources, and calculating the next base from stable tags. GOAT permits an explicit SHA without green CI; this plan retains the owner's green requirement for explicit SHAs as well. +- Live on 2026-11-08, `origin/main` at `667662903a6c139524506667d12656d9ad50fad0` is not an ancestor of `origin/dev` at `0627a2c255bc239d5748119f1d0bc4457a1c03e3`. Do not infer release lineage from tag ordering. Identify the exact prior stable's source and compare trees/ancestry at cutover; where promotion commits are divergent, require an explicit, reviewed one-time migration baseline with evidence rather than an implicit override. +- The coordinator confirmed 2.99.0 publication and lifted the merge hold subject to technical and separate security review, exact-head CI and zero unresolved threads. Fresh read-only checks show npm `latest=2.99.0`, published GitHub release `v2.99.0`, and successful release workflow run `42449453351` at `667662903a6c139524506667d12656d9ad50fad0`. Coordinator confirmed npm trusted publishing is configured for `lidge-jun/opencodex`, `release.yml`, no environment; repository settings and real release dispatch remain coordinator-only. +- Baseline evidence on 2026-11-08: `npm view @bitkyc08/opencodex dist-tags --json` returned `latest=2.99.0` and `preview=2.99.0-preview.20261108`; `npm view @bitkyc08/opencodex@2.99.0 gitHead --json` returned `667662903a6c139524506667d12656d9ad50fad0`. `git diff --name-only v2.99.0 origin/dev` and the same comparison for `v2.99.0-preview.20261108` each returned exactly the four version source files. The stable, preview and dev trees are respectively `8cd5fe4a2a27091ac41875eb63d433b3cb815196`, `7ea1dbee63f5eb69704c86ba013abc1291ea4d8b`, and `e05ea25d6cba2071775064b2362cb75c9506cdcf`; the dev commit compared was `0627a2c255bc239d5748119f1d0bc4457a1c03e3`. These observations are a proposed baseline, not maintainer approval or proof of every historical npm publication. +- Architect consultation: an independent architect proposed R1–R7. Stable remains an explicit maintainer action and need not have a prior preview or soak; every new channel publication must be at least as new as the most recently published source across both channels, with equal source allowed for stable following preview; existing release receipts and resume safeguards must be preserved where verifiable. Reflection twice identified gaps; the plan was amended and the third reflection aligned. An independent plan audit passed after further amendments. The owner subsequently narrowed signing scope as stated above. + +## Contract + +1. **Green.** A qualifying run is this repository's `ci.yml`, `workflow_dispatch`, `head_branch=dev`, completed with conclusion `success`, full `lane=all`, and an exact full source SHA. Give full runs a machine-readable immutable run name or receipt that distinguishes the lane; reject missing, malformed, failed, cancelled, skipped, scheduled, PR, wrong-branch, and narrower evidence. Confirm the aggregate and required job graph succeeded. The lookup walks all relevant pages, not a fixed ten-run window. Select the newest qualifying commit by ancestry on current `origin/dev`; a supplied full SHA must itself qualify. Build the chosen SHA even if dev advances. Recheck eligibility immediately before publish. +2. **Lineage.** Fetch complete history and tag refs. Candidate must be reachable from current dev. Every new release must contain the most recently published source across channels; same SHA is allowed for preview-to-stable and a verified identical resume, otherwise older/divergent/unresolvable lineage fails. Legacy promotion commits need the explicit transition baseline above. Tag `v` and GitHub release target the source SHA, not the workflow SHA. A release uses current reviewed dev workflow logic even when source is older; trusted guard scripts are also read from workflow revision. Hosted release runs from PR-controlled workflow revisions are prohibited. +3. **Versions.** The four committed sources are a consistent plain planned base. Candidate base is the higher of that base and the patch after the highest published stable; major/minor advances require a normal reviewed manifest edit. Stable is `X.Y.Z`; preview is `X.Y.Z-preview.YYYYMMDD.N` with UTC date and an ordinal that advances for that date. Check the global published version floor and npm dist-tags again at publish; a preview never sorts below an existing stable and neither channel can regress. Each build checkout runs sync then check before installation, building, signing or packaging; artifacts and receipts report the stamped version and source SHA. Update the dev version-line oracle so an intentionally unchanged base is valid after a release; retain checks against malformed or conflicting sources. +4. **Service.** Compare all lifecycle-sensitive paths from the last applicable reachable release baseline to the entire candidate tree; the initial baseline compares the complete tree. If changed, require a successful full Linux/macOS/Windows `service-lifecycle.yml` dispatch bound to the exact source SHA, not a PR run. Make dispatch accept a validated SHA independently of its workflow revision so a historical green candidate can be tested after dev moves; validate the exact tested SHA and job graph. Missing evidence fails with the command to obtain it. No dev push trigger is added. +5. **Serialization and recovery.** Preview and stable share the existing `release` concurrency group, with cancellation disabled. GitHub can replace a pending run; never claim FIFO or that an unstarted run published. Recheck registry state, version ordering, tags, source lineage, CI, service, and artifact receipt immediately before publication. Preserve dry-run behavior and immutable source/version/digest verification; a resume after npm publication must bind the identical source, version and registry `gitHead`, never republish npm, and never overwrite different release assets. Rerun failed jobs may reuse verified artifacts only while available; if proof is unavailable fail closed and document operator recovery. Recheck preview-versus-stable ordering under the shared lock. +6. **Branch and default policy.** Set `dev` as default only through coordinator action after landing and cutover checks. Retain `main` and `preview` as protected historical branches; do not automatically mirror `main`, because its protection and divergent promotion commits make a fast-forward unreliable. Retire them only after consumer inventory and separate authorization. Switch docs deployment to a manually dispatched stable-source build, preserving stable documentation without a branch push; verify Pages workflow and environment restrictions. Remove release-branch CI push triggers and the full nightly CI schedule: manual dev dispatch is the full cross-platform path; PR fast Ubuntu checks remain unchanged. Adjust service and catalog workflow triggers accordingly, with no dev push or PR macOS/Windows. Audit CodeRabbit, branch rulesets, required checks, `enforce-target`, issue/PR default-base behavior, workflow schedules, Actions default-branch availability, Pages, and release automation before requesting settings changes. +7. **npm identity.** Publish remains in `.github/workflows/release.yml`, job `publish`, `id-token: write`, with no GitHub environment. Coordinator must verify npm trusted publisher repository owner `lidge-jun`, repository `opencodex`, workflow filename `release.yml`, and no environment. Retain pinned actions and least-privilege permissions. No secrets are printed. + +## Evidence authentication and credential boundary + +- Pin the release workflow and its guard revision once for the run and retain it alongside the candidate source SHA. The trusted guard owns an explicit versioned policy allowlist for CI and service workflow policy: require the exact workflow path, source repository ID, workflow revision's blob hash, and policy version to match an approved entry. The policy fingerprint also covers all local helpers and actions that produce evidence or select required jobs, or executes those from an authenticated trusted revision; a matching YAML alone is insufficient. A future policy change updates that reviewed allowlist; an unknown revision fails closed. A title, job name, or artifact alone is never authentication. The run must be on `dev`, be a manual dispatch, and GitHub's run API must report the expected repository, workflow, head SHA, run ID, **actual attempt**, completion and success. Validate the jobs of that attempt, including every expected matrix leg and the aggregate result; fail on a missing or skipped required leg. The CI identity binds `lane=all` and tested SHA to this same attempt. Exercise forged titles, old policy revisions, altered helpers, wrong source repo and mismatched attempts. +- For service, the authenticated trusted workflow renders the validated full source SHA directly from its dispatch input into each platform job's GitHub job name before candidate code runs, then checks out that exact SHA on Linux/macOS/Windows. The verifier reads the GitHub run and attempt-specific jobs APIs, checks the approved workflow and helper policy, exact SHA-bearing names, each platform's success, and the full single-attempt graph. A candidate-generated receipt or run-scoped artifact alone is never proof of its producer or attempt; if API data cannot prove the binding, fail closed. Test changed inputs, stale/mismatched names, duplicate jobs, full and partial reruns and wrong workflow revisions. Never use a service run's workflow `head_sha` as its tested source when dispatching an older commit. +- Build and package candidate-controlled scripts and local actions without OIDC or write permissions; inventory every execution path including npm lifecycle hooks. The OIDC publisher consumes only a previously verified immutable package artifact and trusted workflow-revision guard, verifies source/version/digest, and invokes npm publication without executing candidate lifecycle scripts (for example publish the verified tarball with scripts disabled). Do not run candidate-controlled code after granting OIDC or GitHub write permissions. Verify packed metadata, npm `gitHead` and provenance really identify the selected commit using an actual dry run and subsequent publication checks; do not claim that dry-run alone proves registry provenance. Any unavoidable write-capable code must be separately documented and reviewed. +- Preserve the existing Apple, SignPath and updater signing paths and their existing + credential boundaries. Only trusted, reviewed `dev` ancestors may be selected; + a PR-controlled workflow must not gain access to release credentials. Independent + security review checks whether the new source-selection flow worsens the existing + boundary. Never print secret values. +- Production publish requires repository variable `OCX_DEV_RELEASE_PUBLISH_ENABLED` to equal exactly `true`; missing, empty or other values fail closed immediately before any irreversible publish. Dry runs do not require it and cannot publish. The coordinator alone enables or disables the variable after the post-merge dry run and settings checks, and verifies both missing/false refusal and true behavior using publication-disabled tests before enabling. This is an operational hold controlled by repository administrators, not a barrier against an administrator changing the variable or workflow. + +## Independent plan audit amendments + +The first independent plan audit returned FAIL with seven high-severity findings; all are accepted for the implementation and must be re-audited: + +1. **CI attempts:** a qualifying attempt must contain the entire required graph in that same attempt. `Re-run failed jobs` is insufficient even if GitHub reports overall success; operators must select `Re-run all jobs` or start a new full dispatch. Tests distinguish full reruns from partial reruns and reject absent legs. +2. **Graph:** make a manual `lane=all` select a fixed, versioned list of required jobs and matrix legs regardless of path filters, while preserving fast PR behavior. Verify this specific graph against the authenticated policy and attempt; any required skip or missing leg fails. Test every fixed leg and PR exclusions. Do not claim a conditional graph is fixed without implementing and proving it. +3. **Resume identity:** a fresh dispatch derives a new version; a resume must instead supply the original source, channel and version plus a durable receipt identifying workflow/run, artifact digests and publication state. Validate receipt, registry package version and source identity, existing tag, release and assets before allowing the exact original tuple; only then bypass new-version derivation. Test failures after npm publication, tag creation and partial asset uploads, including missing/expired receipt and mismatched digest. An unverifiable case stops for manual reconciliation. +4. **Historical tag permissions:** before irreversible npm publication, verify that the selected credential can create a tag/release at a candidate whose workflow differs from default. The dry run must check the applicable capability without creating production tags or releases; a dedicated sandbox publication experiment is coordinator-authorized separately. If the GitHub token cannot do it, fail closed until the coordinator provisions a reviewed least-privilege GitHub App credential with only necessary repository contents/workflow permissions, or choose a documented safe alternative. Do not assume this credential or change settings. +5. **Untrusted dry runs and secrets:** do not dispatch hosted release workflows from a PR branch: branch-controlled YAML can request credentials regardless of its own conditions. The required hosted dry run runs only from trusted `dev` after merge; its source SHA is validated independently. Test a candidate changing workflow YAML and local helpers to request release secrets or write permissions; trusted-policy execution must refuse candidate policy and credential-bearing jobs must not execute candidate-controlled code. Production credential inventory includes Apple, SignPath, Tauri updater and notarization keys; isolated signing or an explicitly reviewed exception applies to all of them. +6. **Legacy cross-channel mapping:** inventory actual publication order, tag commits and source trees for both stable and preview before the first new release; the currently fetched `v2.99.0-preview.20261108` commit is not a dev ancestor. Record reviewed, narrow mappings for every legacy source that the cross-channel comparison actually requires; no one stable-only mapping silently exempts a later preview. +7. **Provenance experiment:** trusted verifier code comes from the pinned workflow revision, never the candidate checkout. The immutable package receipt binds source SHA, workflow SHA, run/attempt, version and tarball digest; publish verifies it before obtaining OIDC. Resume accepts only registry metadata identifying the exact package version and original source under an experimentally verified `gitHead` rule and an independently verifiable trusted-publisher provenance statement. Perform a safely isolated npm/GitHub publication experiment only with coordinator authorization, document observed metadata and credential limitations, and keep production disabled if exact source binding or provenance cannot be established. A local or hosted dry run alone cannot establish registry provenance. + +**Second audit amendments:** npm's configured publisher has no environment restriction, and branch-controlled workflow YAML cannot protect secrets from a repository writer who can dispatch a modified `release.yml`. This design explicitly trusts such repository writers; the no-PR-dispatch rule is an operator policy, not an enforced credential barrier against them. The coordinator retains the existing no-environment publisher; a protected environment and a matching npm publisher configuration would be a separate coordinator-controlled hardening decision. CI/service job names and conditions also do not defend against an administrator or writer who can change the trusted policy allowlist. + +The cross-channel publication floor includes acknowledged npm-only partial publications, not merely GitHub releases. Before new publication, reconcile the authoritative ledger against npm versions/dist-tags, successful and partially acknowledged prior release runs and retained authenticated receipts. Establish the original source, version and ordering from receipt and registry metadata; if an npm-only publication, missing receipt, uncertain ordering or unverifiable source prevents this, fail closed until the coordinator reconciles it. Test a newer npm-only publication followed by an attempt to publish an older source, including missing and forged receipts. The shared workflow lock and operator-controlled reconciliation are required for this ledger; GitHub release tags alone are not its authority. + +## Cutover and rollback + +1. Inventory the current stable and preview publication floors. The validator checks the fixed 2.99.0 legacy sources and dev baseline against exact SHAs and verifies that their trees differ only in the four expected version values; the evidence is recorded above. The mapping is narrowly scoped to those commits, not a general ancestry override. If the exact comparison or ancestry cannot be demonstrated, stop publication and reconcile the history. Subsequent releases require direct cross-channel ancestry. +2. Merge only after the owner's local validation, fresh independent technical and security reviews, required exact-head CI and resolved human maintainer change requests. Bot reviews and cosmetic or pre-existing findings are not merge gates. Hosted release dry-run is a documented pre-merge limitation: run it from trusted dev after merge. Keep production publishing disabled until that full post-merge dry-run and cutover checks pass. +3. Coordinator verifies npm publisher identity and inventories `dev` ruleset bypass actors, required check names, CodeRabbit, Pages environment/ref restrictions, branch targeting and default-branch-dependent jobs. Coordinator switches the repository default to `dev`; verify those settings and manual workflow availability before enabling release publication. If verification fails, leave publication disabled and restore the prior default/settings under coordinator control. The old workflow relies on the default branch's dispatch guard: it is a rollback option only after the coordinator restores the old default and verifies the old workflow, guard, CI/service evidence, npm identity and settings; do not assume it remains usable after switching defaults. +4. Manually dispatch one `lane=all` on dev and, when required, service lifecycle for that exact candidate; run the release with `dry-run=true`, inspect all artifact/platform verification and source/version receipts, and validate Pages by deploying stable-source documentation through its approved manual path. Only then enable normal release operation. If dry-run or Pages fails, keep the old release runbook and historical refs available; do not silently fall back to a weaker gate. +5. Preserve `main`, `preview`, old releases and tags until downstream consumers and recovery are audited. Disable redundant push/schedule triggers in the merged workflow rather than deleting branches. Resume requires the same source/version plus verifiable registry identity and artifact digests; if artifact evidence has expired or differs, stop and have the maintainer reconcile the partial publication instead of overwriting assets or republishing. + +## Change map and acceptance + +The implementation-level work order is recorded in `010_implementation.md`. The regression suite must first demonstrate red for incorrect green selection, older/divergent sources, version derivation/order, independent workflow/source SHAs, service evidence, and resume, then pass after changes. Existing release-helper tests, CI workflow policy tests, version-source/asset tests, `bun run typecheck`, `bun run structure:check`, and `bun run privacy:scan` cover their respective code paths; `bun run test` is the default review gate. A dry-run of the new workflow against a qualifying dev SHA is required after merge. Full hosted dry-run and OS-specific packaging cannot be replaced by local tests. + +**Baseline verifier observations:** `bun run structure:check` exited 0 and reads `structure/manifest.json` and mapped docs. `bun test tests/ci-workflows/release-version-line.test.ts` initially failed to load `zod/v4` before dependencies were installed; `bun install --frozen-lockfile` then exited 0. Re-run verifiers after edits; a named command is not evidence it ran. diff --git a/devlog/_plan/261108_dev_single_branch_release/010_implementation.md b/devlog/_plan/261108_dev_single_branch_release/010_implementation.md new file mode 100644 index 00000000000..2b1ed084fed --- /dev/null +++ b/devlog/_plan/261108_dev_single_branch_release/010_implementation.md @@ -0,0 +1,17 @@ +# Implementation and validation map + +This is one integrated work phase: release publication cannot safely use the new selector before every checkout, version gate, receipt and recovery path uses the selected source SHA. The following order describes dependencies inside that change, not independently shippable partial releases. + +Owner scope clarification on 2026-11-08 supersedes signing redesign language +below: preserve existing desktop signing and address only regressions introduced +by this release flow. Historical signing hardening is follow-up work. + +1. **Evidence and versions.** Modify `.github/workflows/ci.yml` to expose a stable machine-readable full-lane identity and retain its aggregate job assertions; add a release-owned selector under `.github/scripts/` or `scripts/` that validates authenticated repository/run/attempt, approved workflow blob hash, full paginated job graph, lane identity, full SHA, ancestry and the explicit legacy baseline. Extend `scripts/version-line.ts` with pure next-base and preview ordinal decisions. Modify focused `tests/ci-workflows/release-*.test.ts` and fixtures for forged titles, unknown workflow revision, wrong event/repository/branch/lane/attempt/conclusion, incomplete pagination, equal/older/divergent SHAs, legacy baseline, stable/preview semver and chronology; demonstrate these tests fail before implementation. +2. **Service evidence.** Modify `.github/workflows/service-lifecycle.yml` to dispatch a validated candidate SHA using the workflow revision's policy, check it out in all jobs, bind the resulting successful full matrix to that SHA, and avoid cancelling unrelated dispatches. Test no changes, accumulated service changes, missing/failed/PR/narrow/wrong-SHA evidence, and historical-SHA dispatch after dev advances. Keep PR macOS/Windows disabled and add no dev push. +3. **Release integration.** Modify `.github/workflows/release.yml`, `.github/scripts/release-dispatch-guard.cjs`, `scripts/ci/release-preflight.sh`, `scripts/release.ts` and affected verification/resume/release-notes helpers. Resolve source and version once under current dev workflow policy; pass outputs to every checkout and replace source-dependent `GITHUB_SHA` uses with the resolved SHA, including assets, npm `gitHead`, release notes, tags and resume. Stamp/check all four sources in every job before any build. Build npm tarballs in a no-OIDC/no-write job; a separate publisher verifies immutable artifact digest and runs no candidate-controlled scripts. Isolate Apple/SignPath signing from candidate builds where possible; explicitly document and independently review any build-time signing exception. Enforce a fail-closed `OCX_DEV_RELEASE_PUBLISH_ENABLED=true` hold for non-dry-run publication; test missing, false and true. Keep permissions scoped per job; do not run hosted release workflows from PR branches. Verify every existing safety gate, immutable action pin, asset validation and exact-SHA resume still applies. Test installer, updater and widget versions, preview-to-stable behavior, each refusal and absence of publication on failure. +4. **Policy, documentation, migration.** Update `MAINTAINERS.md`, `AGENTS.md`, `structure/ops/docs-and-release.md`, `structure/ops/cross-platform-ci.md`, and affected docs-site release/contributor translations. Update `.github/workflows/deploy-docs.yml`, `.github/workflows/catalog-async-contracts.yml`, branch enforcement and relevant CI tests; retain historic branches and the old bump workflow only as an explicitly disabled/manual migration tool or remove it only with separately authorized deletion. Explain exact coordinator settings, Pages stable-source deployment, OIDC identity, one-time lineage baseline, and 2.99.0 handoff. +5. **Review and proof.** Independently review technical correctness and security using separate `task.create` review lanes; record accepted/rebutted findings and rerun targeted checks after fixes. Run focused tests, `bun run typecheck`, `bun run structure:check`, `bun run privacy:scan`, and `bun run test` unless resource exception applies. Check complete workflow diffs and artifact/platform constraints. Open PR against dev with template and exact verification; link it through GOAT. Inspect current-head required CI. Request no production release or settings changes. Once approved for merge and merged, obtain green `lane=all` evidence and run `release.yml` with `dry-run=true` from trusted dev, reporting the actual run, SHA and result. Do not dispatch a release dry run from a PR-controlled workflow. + +Apply the seven independent-audit amendments in `000_plan.md`: fixed full-dispatch graph and full-attempt evidence, explicit resume tuple, historical tag capability and provenance experiments as cutover blockers, zero release secrets on untrusted dry runs, cross-channel legacy mappings, and trusted-revision verifiers. Tests must exercise every refusal and the operator recovery paths. + +**Threat boundary:** dispatch inputs, candidate source, run metadata, artifacts, registry responses and legacy tags are external inputs; trusted workflow revision, maintainer-approved dev history and repository permissions are the authorities. Threats include publishing untested or older code, wrong-source artifacts, credential exposure and partial publication. Controls are validated immutable source bindings, full job evidence, shared serialization, final rechecks, scoped tokens, pinned actions, and fail-closed recovery. These controls rely on GitHub repository administrators, workflow permissions and npm identity; administrators can change policy, so workflow checks are not an independent barrier to a malicious administrator. diff --git a/devlog/_plan/261108_dev_single_branch_release/020_stale_branch_cleanup.md b/devlog/_plan/261108_dev_single_branch_release/020_stale_branch_cleanup.md new file mode 100644 index 00000000000..ed56b4cc6c6 --- /dev/null +++ b/devlog/_plan/261108_dev_single_branch_release/020_stale_branch_cleanup.md @@ -0,0 +1,93 @@ +# Stale `main`/`preview` reference cleanup + +**Trigger:** owner deleted the `main` and `preview` branches on 2026-11-09 and +made `dev` the repository default. This unit already assumed a single-branch +line; the branch deletion leaves references that now address branches that do +not exist, or that describe a default-branch state which has changed. + +**Scope:** remove or correct only those stale references. This is a cleanup, +not a CI-policy change. + +## What changes + +- **Dead workflow triggers.** `.github/workflows/codex-queue-helpers.yml` and + `.github/workflows/issue-quality-tests.yml` drop `push.branches: [main, + preview]` and keep `pull_request` (plus `workflow_dispatch` where present). + `cleanup-orphaned-workflows.yml` drops its one-time `push` on `main` and keeps + the daily `schedule`; `react-doctor.yml` drops its `push` on `main` and keeps + the PR scan. No dead push is replaced with a `dev` push — the CI policy + forbids a `dev` push trigger. +- **Service lifecycle.** `service-lifecycle.yml` narrows PR `branches: [main, + dev]` to `[dev]`, which still excludes stacked-child head bases exactly as + before (the old list matched `main`, never stacked heads either). +- **Trusted checkout refs.** `enforce-pr-target.yml` and `pr-hygiene.yml` select + the trusted script checkout with a fixed `dev` ref. `status` events in + `enforce-pr-target.yml` keep using `github.event.repository.default_branch`, + which is now `dev`. PR base/head never selects the ref. +- **Obsolete main-ancestry policy.** `enforce-pr-target.yml`, + `.github/scripts/pr-quality.cjs` and `pr-quality-messages.cjs` lose the + `main...head` compare and the `wrong_ancestry` failure built on it. The + readiness claim check against the PR's own base (`behindBase`, + `READINESS_LATEST_DEV_BEHIND_MAX`, fail-closed on unknown) is separate policy + and stays. Stacked-child exemption stays. +- **Comments/docs.** `ci.yml` default-branch comments, workflow loader comments + in `enforce-issue-quality.yml`, `pr-labeler.yml`, `stale-needs-info.yml`, + `closed-pr-branch-cleanup.yml`, `AGENTS.md`, `MAINTAINERS.md`, + `structure/ops/docs-and-release.md`, `structure/desktop-shell.md`, and the + workflow/doc tests that pin those strings. + +## What does not change + +Historical `main`/`preview` **tag** mappings, release-lineage policy, the +`protected historical TAG mappings` in release evidence, lidge runner routing, +the PR-only security boundary, the `status` default-branch semantics, stacked +PR support, or the no-`dev`-push / no-PR-macOS-Windows CI policy. + +`ci.yml` is edited only in comments. Because the release evidence policy pins +its blob, the final fingerprint entry is appended (history preserved), which +also repairs the pre-existing drift from `28b38f9150` (that commit changed +`ci.yml` without appending a policy entry). + +## Verification + +Failing-first: the workflow-trigger, trusted-checkout, and ancestry tests are +updated to the intended contract and run red before the implementation. Then: +targeted `node --test` script tests, targeted Bun workflow tests, +`bun run typecheck`, `structure:check`, `test-layout`/ratchet/Lab guards, +`privacy:scan`, `release-evidence.test.cjs` + `policy-check`, and +`actionlint`. + +### Recorded results (this branch, based on `28b38f9150`) + +Failing-first: the workflow-trigger, trusted-checkout, default-branch and +ancestry contract tests were rewritten to the intended behaviour and run red +against the old tree before the implementation. After the implementation: + +| Check | Command | Result | +| --- | --- | --- | +| Typecheck | `bun run typecheck` | pass (2,341 files) | +| Privacy | `bun run privacy:scan` | pass | +| Structure SSOT | `bun run structure:check` | pass | +| Lab / ratchet / layout guards | `bun test tests/lab/core-lab-boundary.test.ts tests/ci-workflows/file-size-ratchet.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | 63 pass | +| Workflow suite | `bun test tests/ci-workflows` | 1,864 pass, 27 skip, 0 fail | +| Script suites | `node --test` on `pr-quality`, `pr-quality-messages`, `enforce-pr-target`, `pr-hygiene`, `release-evidence` | 195 pass | +| README parity | `bun test tests/ci-workflows/docs-readme-translation-parity.test.ts` and `docs-readme-memory-inventory.test.ts` | 53 + 33 pass | +| Generated client matrix | `bun run docs:clients:check` | current | +| Evidence policy | `node .github/scripts/release-evidence.cjs policy-check` | fingerprints match | +| actionlint | changed workflows | only the two pre-existing findings, identical at `28b38f9150` (3× `SC2016` in `ci.yml`; `copilot-requests` scope unknown to actionlint in `enforce-issue-quality.yml`) | + +Consequences: the deleted branch pushes are gone rather than retargeted, so no +`dev` push trigger was added; PR, schedule, and manual coverage is retained. +`react-doctor.yml` loses its per-push health-score trend scan, and +`service-lifecycle.yml` now fans out on `dev`-based pull requests only, exactly +as it already excluded stacked-child bases. The release-evidence policy gains a +new final fingerprint entry (earlier entries untouched), which also repairs the +pre-existing `ci.yml` drift at `28b38f9150`. + +The `docs-site` build was run on this tree: 641 pages, 93,177 internal links +checked, Pagefind index built. + +Not run: full `bun run test`. This change touches workflows, docs, and +GitHub-script tests; the suites above cover every changed area, and the +repository permits focused validation when a full local run is +disproportionate. CI owns the remainder. diff --git a/devlog/_plan/261109_provider_target_drafts/000_plan.md b/devlog/_plan/261109_provider_target_drafts/000_plan.md new file mode 100644 index 00000000000..c0837cc10e2 --- /dev/null +++ b/devlog/_plan/261109_provider_target_drafts/000_plan.md @@ -0,0 +1,85 @@ +# Provider drafts and target changes (#8325) + +Provider Settings and JSON edits belong to the server on which editing began. This unit finishes the existing candidate with target discovery, draft lifecycle and save-boundary verification, then records the maintainer's value, UX and compatibility judgment before publication. The user intentionally gains preserved drafts and explicit conflict resolution; ordinary single-target editing and existing configuration formats stay compatible. + +## Scope and workflow + +- Archetype: satisfy-spec, one remaining implementation/acceptance unit. +- Trigger: owner-required PABCD and maintainer judgment, 2026-11-09. +- Goal: target-specific drafts, truthful target labels and saves, no unintended cross-target effects. +- Non-goals: #8326/#8351, generic account redesign, credential persistence, releases, native-stack creation, real-user configuration changes. +- Verifier: focused GUI and relay tests, root layout/ratchet/Lab guards, typecheck, lint/i18n/build, privacy and structure checks, synthetic two-target browser exercise, fresh exact-head independent and security reviews, hosted PR CI. +- Stop: issue-linked PR merged only after all acceptance and owner judgment are supported; unresolved items hold publication/readiness/merge as appropriate. +- Artifact: this plan and 010_acceptance.md; private investigation and detailed review synthesis remain in .tmp/8325-pabcd/. +- Outcomes: validated merge, or explicitly documented hold with remaining evidence. +- Escalation: incompatible legacy transport contract or inability to demonstrate target-bound mutations requires main disposition before implementation; no guard bypass. + +## Starting point and authority + +Candidate d625b8c06aeff5481f62ea19fd4f70354eca46ac already exists on base 295159fb59c7afc094c7fd5c65a78635cbc207d2. It predates this formal cycle and is not retrospectively represented as audited implementation. The current P cycle plans remaining acceptance and repairs. Session: 01a1bb00-c621-73e0-baaa-e88ca7d2b746. No host goal or loop was created. + +One ordinary PR against dev. No rebases; merge dev only after GitHub reports CONFLICTING. Screenshots go only to pr-assets, linked by commit SHA. Full local suites may use the documented resource exception with focused evidence and explicit NOT RUN; test:changed is not evidence for subprocess-only behavior. Batch cross-platform dispatch stays with coordinator. + +## Decisions and consultation + +Architect: thread:delegated-task:command%3Amcp%3A59b13298-b9f9-4693-8cbd-c7bfd07c45f9%3Adelegate-task%3A8325-pabcd-architect-d625. Proposal retrieved 2026-11-09. Revision-1 reflection returned ALIGNED; main folded G1 and A1-A7 in revision 2. Revision-2 independent audit returned GO-WITH-FIXES with four blockers; dispositions follow in revision 3. + +- D1 ACCEPT WITH AMENDMENT: stable logical origin/link identity, separate transport/request generation, event-driven rediscovery plus focus. Same-hub key rotation must not discard drafts. Capture discovery generation; coalesce session events; do not remount the draft-store owner. Add bounded connected-only reconciliation if events cannot detect external remapping. Standalone users start no connected-target polling. +- D2 ACCEPT WITH AMENDMENT: expected-target relay precondition checked against the same immutable connection snapshot used for forwarding, before upstream dispatch. Consume/strip internal headers. Capability/legacy behavior and direct transport must be explicitly audited; an unsupported header is not proof of protection. Prefer existing protocol and endpoint seams. The reader sees only intentional target-change feedback. +- D3 ACCEPT: per-entry revision, mount ownership and reset/removal generation; async completion uses compare-and-set. Preserve newer edits in the same live instance; reject completions from replaced or discarded instances. +- D4 ACCEPT: deep field-wise pacing rebase with explicit conflicts, retaining unrelated model/key edits. +- D5 ACCEPT: one conflict predicate shared by banner, disabled state and all save entry points. +- D6 ACCEPT: all non-empty numeric fields validated; an empty patch is not invalid-input success. +- D7/D8/D11 ACCEPT: logical identity and request generation flow through CRUD, OAuth, generic/Codex pools, roster recovery and quota consumers. No stale target effects or account-id requests after generation changes. +- D9/D10 ACCEPT: own-key-safe JSON merge and labels derived from safe origin text, never composite key material. +- D12 ACCEPT: app-level activation, actual synthetic relay requests, visual evidence, compatibility tests and exact-head reviews/CI. +- PATCH baseline option: AMEND to an audit decision, not an automatic broad API change. Prefer existing update preconditions; if a new optional baseline is needed for intended same-field conflict semantics, specify old-caller compatibility and add server regression coverage before Build. + +## Verification already observed + +On d625, main ran 43 targeted GUI cases and 63 root layout/ratchet/Lab cases, all passing; typecheck, privacy:scan and structure:check exited 0. These commands directly name the changed tests or validate tracked source/docs. This is starting evidence, not acceptance of the remaining target lifecycle. Author build/lint and reviewers' larger GUI runs are separate reports; final evidence is bound to the eventual exact head. Live two-target browser E2E and final hosted CI are NOT RUN. + +## Maintainer judgment required before merge + +VALUE: issue remains real and not superseded; verify issue/PR search live. NO UNINTENDED UX CHANGE: document preserved drafts, target labels and explicit conflict resolution; verify untouched flows/defaults/config formats and old transport behavior. NO REGRESSION: failing-first witnesses plus surrounding behavior and installed/legacy contract checks in isolated homes. Post a short Maintainer judgment comment citing commits/tests and both exact-head review receipts. Green CI alone never justifies merge. Use normal squash with match-head; admin only when GitHub requires an unavailable review approval under the owner's rule. + +## Revision 2: main disposition of architect reflection + +Reflection task 8325-architect-reflection-r1 returned ALIGNED for D1-D12, with material G1 and amendments A1-A7. Main folds them as follows; no product change has been made in this P phase. + +G1: choose L-A, capability advertisement plus legacy preflight. New machine status advertises `relayTargetCheck: "expected-target-v1"`. A supporting relay validates the captured expected target before forwarding. A legacy relay is checked immediately before a mutation; mismatch refuses, requests discovery, and preserves drafts. A one-time target-scoped notice explicitly says the legacy preflight is not an atomic relay guarantee. Never imply the header alone protects an older runtime. Old clients without the optional field retain their existing behavior. No strict header-less rejection is introduced. + +A1: stable identity is shared.serverOrigin plus link.linkId in link mode; direct hub uses hub origin, standalone uses window origin. API-key rotation is not a target change. Logical key, discovery/request generation, and entry reset/removal generation are distinct types/contracts. No machine-status poll starts on standalone. Reconciliation must not destroy a successfully minted session for an unchanged target. + +A2: HubRelayTarget is the immutable forwarding snapshot. The relay consumes `x-opencodex-expected-target` before header filtering; compare canonical origin from that same target.managementUrl. Mismatch: HTTP 409 with `{error:"target_changed"}` and Cache-Control:no-store, no upstream call. Link mode remains read-only and compares the link identity where relevant. Capability support belongs to the machine listener, not the upstream hub. + +A3: entry tombstones last for the page lifetime and are bounded by visited target/provider pairs. A new provider instance supersedes its deletion generation. Discard advances the reset/mount authority before clearing. Saves compare captured authority before any store write or clear. + +A4: pacing conflicts display paths for model rule fields and key rule fields. Empty numeric text means unset; nonempty invalid text must never yield success. Validate top-level, per-key and per-model rules against the existing runtime's accepted bounds; verified bounds are RPM 1/60..60_000, integer delay 1..3_600_000, and integer concurrency >=1 without a new upper cap. Each nonempty rule needs at least one valid field. + +A5: add explicit immediate-roster, retry-identity and late add/remove/alias action cases. Codex and generic account actions cannot send old-target identifiers through a new target generation. + +A6: merge output uses null-prototype objects or own data-property construction, preserving prototype-named keys without changing the prototype. Label fallback displays only the text before the first NUL separator; a composite key never appears in the DOM. + +A7: required activation is a browser using the real App, two synthetic local HTTP target servers and an actual local relay handler, with isolated homes and synthetic config. This is accepted as deterministic two-target integration evidence; live external service accounts are neither required nor authorized. Component-only rerender screenshots do not satisfy it. The evidence labels every fixture seam. Each new behavioral witness must first fail against d625 (or an explicitly scoped ablation where the interface did not exist), then pass on the implementation head. Known existing tests must not be weakened. + +Settings PATCH decision: no new server baseline precondition in this unit. Conflict detection is against the latest saved snapshot the dashboard has observed; an unobserved concurrent edit can still be last-write-wins, as before. JSON retains its existing server baseline contract. The UI and docs must not claim atomic Settings conflict prevention. + +## Revision 3: independent audit dispositions + +Audit task 8325-audit-plan-r2: GO-WITH-FIXES (blockers=4), detailed private evidence .tmp/8325-pabcd/audit-rev2.md. All four are accepted, not waived. B remains held pending reflection of interface/flow changes and re-audit. + +1. D5 amended: explicit conflict resolution by editing must work. On merge conflict, adopt the latest observed saved baseline and a partial merge that includes non-conflicting external changes while retaining user values at conflicting paths; edit/revalidate those paths and rerun the merge before saving. The baseline, banner, disabled state and handler share one conflict authority. An edit is not automatically a valid resolution; unresolved or newly conflicting paths stay blocked. +2. D2/D11 clarified: each mutation captures an immutable request target at the user action boundary, before any await or deferred fetch. The transport must never derive an expected target from its mutable live target at send time. A target generation change before send refuses the original action; an already sent opt-in request is checked by the relay using its forwarded snapshot. +3. Settings server baseline option rejected for this unit as a separate API/semantics change. Preserve existing PATCH compatibility and explicitly document last-write-wins for changes not yet observed. This does not relax cross-target binding or preservation of unrelated observed changes. +4. D7/D8/D11 expanded: 30-day usage cache, Codex module-level seeds and Add Provider preset cache join the logical-target consumer map, so a stable relay URL cannot reuse another target's data. + +Bounded audit additions accepted: pre-body relay check; duplicate/oversized field refusal; no unused link mutation header (link remains read-only); page-memory target-scoped legacy notice; numeric bounds from src/config/schema/leaf-validators.ts; bounded draft lifecycle metadata; safe own-key merge and NUL-free labels; explicit source ownership/test registration and browser activation steps. + +## Revision 4 reflection dispositions + +Architect reflection 8325-architect-reflection-r3: ALIGNED with three material detail gaps. Main accepts G3-1/G3-2/G3-3 and B3-1..B3-7. Revision 4 specifies partial merge editor content, the action-captured targetFetch API and all usage/preset consumers. It removes stale optional-link/PATCH and unverified-bound wording. No product implementation has started in this cycle; same independent reviewer re-audits this revision. + +## Audit acceptance and Build authorization + +Revision-4 re-audit returned VERDICT: PASS with no material blockers. Main accepts the verdict; Build entry clarifications in 010 include the Dashboard wrapper, concrete lifecycle bound, discovery coalescing owner, dependency edges, captured transport, observed-only Settings conflicts and fresh-session fixture restart. Build is authorized for the planned remaining delta, not a retroactive approval of d625. Main retains publication and maintainer judgment authority. diff --git a/devlog/_plan/261109_provider_target_drafts/010_acceptance.md b/devlog/_plan/261109_provider_target_drafts/010_acceptance.md new file mode 100644 index 00000000000..47d9ab8515c --- /dev/null +++ b/devlog/_plan/261109_provider_target_drafts/010_acceptance.md @@ -0,0 +1,142 @@ +# Remaining implementation and acceptance plan (revision 4) + +This is one dependency-ordered work unit, not separate independently mergeable PRs. P and A are read-only with respect to product code; B starts only after architect reflection and independent audit. + +## File change map and order + +1. MODIFY gui/src/api-targets.ts; NEW gui/src/hooks/useApiTargets.ts (or a smaller existing owner if audit identifies one); MODIFY gui/src/App.tsx and gui/src/api.ts. Stable logical key = normalized advertised origin plus link identity where necessary, not API-key rotation. Discovery creates target generation; configuration and session reconciliation consume it before readiness. Refresh on pairing/session/focus and server mismatch, coalesced to prevent discovery/session loops. Keep parked drafts above any remount. Failure preserves draft but disables unsafe writes with actionable feedback. +2. MODIFY src/client/machine-listener.ts, src/client/hub-relay.ts and relevant GUI transport owner. Define a bounded expected-target request field through creation -> serialization -> parsing -> comparison -> stripping -> forwarding. Compare the exact snapshot forwarded; malformed/mismatched claims refuse without upstream call. Absent-field compatibility remains explicit; only negotiated support may be claimed as a fence. Link read-only behavior stays read-only. No new Settings PATCH baseline is added; conflict handling uses the latest observed snapshot and preserves existing last-write-wins semantics for unobserved edits. +3. MODIFY gui/src/provider-workspace/target-drafts.ts, provider-draft-merge.ts, ProviderSettings.tsx, ProviderDetails.tsx, ProviderJsonEditor.tsx and useJsonConfigEditor.ts. Store entry includes ownership/revision/reset generation, tombstones survive pending operations, conditional commits preserve current edits. Deep merge nested pacing maps, preserve own JSON keys, unify conflict state and validate numeric strings. Same-name re-add does not inherit deleted-instance edits. Label fallback strips non-display identity fields. +4. MODIFY gui/src/usage-summary-resource.ts, gui/src/components/AddProviderModal.tsx, gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx, gui/src/pages/use-dashboard-data.ts, Providers.tsx, use-providers-fetch.ts, use-providers-crud.ts, use-providers-oauth.ts, use-target-oauth-state.ts, useProviderAccountPools.ts and useCodexAccountPool.ts. Pass logical identity plus generation through cache/load/dedupe/mutation/completion. Reject stale callbacks before incrementing a newer request sequence; fence before send and before state/toast updates. Include affinity retry, account roster recovery and quota coordinator paths. +5. MODIFY relevant GUI tests and NEW small app/relay regression files if existing files are at caps. New root tests registered in layout and expected shards. MODIFY all affected locale catalogs, docs-site web-dashboard guide and structure/dashboard-and-usage plus source owners for relay if changed. Never raise ratchet caps or commit screenshot evidence to this branch. + +## Acceptance activation and observations + +- Real App discovery returns A then B at the same relay base: displayed label/config/accounts belong to B; A draft remains parked; returning to A restores it. Same-hub reconnect/key rotation retains logical identity. Verify configure/bootstrap does not erase a just-created valid session or trigger an event loop. +- A request carries expected A while relay current snapshot is B: deterministic refusal with zero upstream requests; matching request reaches A; direct transport and legacy absent-field behavior remain compatible. Test in-flight connection replacement at the forwarding seam. +- A save is pending while edits change, target changes A-B-A, discard occurs, or removal succeeds: only same-instance newer edits survive; stale completions cannot resurrect/reset current drafts, toast on B or use old account ids on B. +- Concurrent saved pacing edits touch different models/subfields: merge both; same-field changes produce explicit resolvable conflict. Concurrent provider addition/deletion and JSON own keys retain correct semantics. Restore uses current saved values. Baseline A-B-A leaves banner, buttons and handler consistent. +- Non-empty invalid RPM/delay/concurrency: validation message, no PATCH, no success notice. Clean external endpoint change updates both field and picker; unrelated save preserves it. +- New target with same provider names refetches all generic/Codex rosters immediately, not after periodic polling. Failed deletes do not remove drafts; confirmed deletes do. +- Synthetic two-target browser exercise uses real App and local relay fixture, no real accounts/homes. Assert request destinations as well as DOM state. Capture changed UI at desktop width; inspect image before publishing to pr-assets. Report precisely which real-runtime or mocked seams were exercised. + +## Verification commands and evidence + +Commands already demonstrated available: isolated bun test --cwd gui --isolate tests/provider-target-drafts.test.tsx tests/provider-target-drafts-store.test.ts tests/provider-same-origin-api-base.test.tsx tests/provider-first-response-guidance.test.tsx (43 pass on d625); isolated bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts tests/ci-workflows/file-size-ratchet.test.ts tests/lab/core-lab-boundary.test.ts (63 pass); bun run typecheck/privacy:scan/structure:check exit 0. They directly observe named tests, src type graph, tracked privacy patterns and structure claims respectively. + +Additional verifiers to validate before final plan acceptance or use: GUI lint/i18n/build scripts; app-target discovery suites; tests/clients/client-hub-relay.test.ts and client-machine-listener.test.ts for the new transport condition; Codex pool and generic account tests; synthetic browser harness. These additional runs are NOT RUN by main for this cycle. Build/test processes use env -i and temporary HOME/OPENCODEX_HOME/CODEX_HOME/CLAUDE_CONFIG_DIR. Never real ~/.opencodex. + +## Responsibility and remaining audit questions + +Main owns plan dispositions, phase transitions, integration, fresh final review requests, CI inspection, screenshot upload and maintainer judgment. One isolated implementation child may implement the audited map; no child may push or merge. Architect reflects on this revision; a different reviewer audits it. Detailed unfixed-boundary notes remain in .tmp, not this public plan. + +Audit must validate the selected capability/legacy and identity contracts; remaining execution details are session-event recursion, the explicit browser fixture activation and bounded tombstone lifetime. No unresolved question may be converted into an asserted guarantee. + +## Revision 2 concrete additions + +### Interface chain + +- `relayTargetCheck`: created by src/client/machine-api.ts status DTO; serialized by machine status response; parsed/validated in gui/src/api-targets.ts (absent/unknown => legacy); consumed by the target-aware mutation wrapper and notice. Existing status consumers tolerate the optional field. +- Expected-target header: created from captured logical target in GUI transport; serialized only for machine relay requests; parsed and bounded at src/client/machine-listener.ts / hub-relay.ts; consumed by comparison against HubRelayTarget, then stripped before forwarding. Missing field preserves legacy callers; malformed/mismatch refuses the opting-in request. Direct requests do not carry this internal relay header. +- Target generations and entry revisions: created in discovery/store owners, in-memory only (serialization/deserialization N/A), consumed by config/cache/roster/mutation/save completion; never substituted for logical draft identity. + +### Added witnesses and proposed test files + +NEW gui/tests/api-target-lifecycle.test.tsx: real discovery/session/pair/focus lifecycle, same-origin relay remap, key rotation stable identity, zero standalone polling, event recursion bounded. NEW tests/clients/client-hub-relay-target.test.ts: capability DTO, exact-match forwarding, mismatch/invalid/no-header behavior, internal header stripping, replacement during dispatch uses one snapshot. Register root test in layout and expected shards. + +MODIFY gui/tests/provider-target-drafts.test.tsx and provider-target-drafts-store.test.ts: entry reset/removal/mount CAS, ABA, same-instance edits, nested pacing merge, current Restore and coherent conflict controls, invalid numeric values, own `__proto__` property and label fallback. MODIFY gui/tests/api-targets.test.ts, use-providers-crud-update.test.tsx, relevant provider account pool and codex-account-pool-behaviour/stale-refresh/handlers tests: per-consumer late-response and no-old-id-on-new-target assertions. + +NEW gitignored .tmp/8325-pabcd/browser/ harness: two local synthetic servers plus the real relay route and real App, target change while a draft is parked and a request is pending; inspect recorded request destinations and visible target/values. Do not commit harness screenshots to this branch. Final screenshot upload is only to pr-assets with immutable commit link. + +First demonstrate every new witness red on d625 or its documented narrow ablation; record command, SHA, exit and failure. Then run the unchanged witness on the fix. The architecture review must validate test command availability and paths before B; new tests cannot be honestly reported as already executed in P. + +### Enforcement and compatibility statement + +Tier: server request precondition plus client early warning. Executing surfaces: expected-target-v1 relay handler and GUI target-aware mutation wrapper. Known bypass: legacy caller omits optional field, legacy relay ignores unnegotiated field, or a client intentionally supplies a different target. Residual risk: legacy preflight has a read/send race, and a target claim is not authentication. Wording downgrade: legacy is explicitly a preflight check, never atomic enforcement. Final layer for opted-in supported relay requests: compare against the same connection snapshot used to forward; no claim of protection for old endpoints. Existing authentication/CSRF stays mandatory and unchanged. + +L-A UX tests: supported listener shows normal target-aware saving; legacy listener preserves existing flows with the explicit target-scoped limitation notice; mismatching preflight sends no mutation, retains draft and triggers discovery. Correct the dashboard guide's unconditional guarantee and old machine-key identity wording. New fields must not appear in CLI output or modify persisted configs. + +## Revision 3 audited amendments (supersede conflicting optional choices above) + +### Mutation capture API and field chain (D2/D11) + +MODIFY gui/src/api.ts and gui/src/pages/use-providers-crud.ts plus the other mutation consumers already listed. Introduce an in-memory immutable capture `{apiBase, targetKey, origin, requestGeneration}` at save/action start. Creation is the UI/hook action boundary; serialization adds the expected-origin header only for a captured relay request with negotiated support; deserialization is bounded relay parsing; consumer is the same HubRelayTarget snapshot later passed to forwarding. Captured authority is checked before send and before apply. A live sessionHeaders lookup may supply authentication only after the captured target is verified; it cannot replace the captured expected target with the new target. + +A target switch between click and actual fetch must result in no mutation to the new target, not a newly labeled request. A switch after send must reach either the captured target or the relay's no-forward 409. Test both barriers deterministically. The relay check precedes readBoundedRelayRequestBody; duplicate, comma-combined, invalid and oversized expected-target values are rejected without reading/forwarding the body. No expected-link mutation field is added: the link branch is read-only, with that invariant tested. Absent headers retain older-caller behavior. + +### Conflict resolution (D5) + +MODIFY useJsonConfigEditor.ts and ProviderJsonEditor.tsx with tests for edit-to-resolve and still-conflicting edits. On a conflicting rebase, show the partial merge: adopt non-conflicting external values, preserve user values only at conflicting paths, adopt the newest observed baseline and retain unresolved conflict metadata. A user can resolve by editing individual paths, not only by discarding all edits. Revalidate at Save and retain unrelated concurrent values. A-B-A saved-baseline changes must leave banner, Save button and handler in agreement. Settings's conflict promise is limited to the latest observed snapshot, not an atomic PATCH baseline guarantee. + +### Remaining cache and completion map (D7/D8/D11) + +MODIFY gui/src/usage-summary-resource.ts to accept logical target identity for provider usage cache keys without changing other callers' default contract. MODIFY useCodexAccountPool.ts module seeds, initial-load key, usage resource, generations and all action completions to use the logical identity. MODIFY Providers.tsx preset and roster recovery keys, and generic pool bootstrap keys. Tests cover same relay URL with different target data, immediate refetch, seed isolation across remount, and late action no-state/no-toast on another target. CRUD remove/disable/default completions are included. + +### Numeric authority, lifetime and ownership + +Use src/config/schema/leaf-validators.ts as the existing accepted pacing bounds for top-level, model and key fields; empty text is unset. Do not add guessed stricter limits. Tombstone/revision metadata remains in page memory with one current record per visited target/provider and explicit eviction that cannot resurrect a still-pending completion; audit the bound and lifetime before implementation. + +Source-of-truth review fan-out includes structure/runtime.md, remote-link.md, fallback-policy.md and gui-and-management-api.md in addition to dashboard-and-usage.md. Update only contracts/explanations actually changed. Register client-hub-relay-target.test.ts in scripts/test-layout/layout-clients.json and tests/fixtures/test-layout-expected.json. + +### Browser and verifier activation + +Use `bun run build:gui` for the exact candidate, then serve its real App bundle with two synthetic local HTTP target servers and the actual machine relay handler. The fixture changes its live connection from A to B while keeping the relay URL fixed. Trigger the real discovery lifecycle (pair/session/focus or negotiated mismatch); assert no A mutation reaches B and A draft survives return. Preserve same-origin/session handling and record simulated seams. No external production hubs/accounts are used. + +Explicit existing verifier commands: `bun run lint:gui`, `cd gui && bun run lint:i18n`, `bun run build:gui`, root typecheck/privacy/structure, focused GUI and client relay tests. Independent A verified existing relay/machine suites 41 pass, GUI CRUD/editor 18 pass, Codex pool 47 pass, lint/i18n pass at d625. New test files, build and browser activation are NOT RUN in this plan; their failure-before-fix and success receipts are B/C work, not invented evidence. + +## Revision 4 exact interface and activation details + +D5: `threeWayMergeJson` returns the partially merged value together with conflict paths. Non-conflicting current server values are present in the editor after Keep; conflicting paths retain user values and are marked unresolved. The editor records the resolution baseline and user-change intent rather than reinterpreting the untouched old whole document as a new edit. On text edits, parse and recompute conflict/resolution state; explicitly edited conflict paths can be resolved, while untouched paths remain flagged. Save stays disabled while unresolved and the handler also checks the same predicate. Once resolved, Save re-merges against the latest observed server state and sends that merged next plus its corresponding baseline. A later external change reintroduces conflicts as needed. Tests assert the editor's partial value, preservation of concurrent provider additions, edit-to-resolve, still-conflicting edits and a second external change before Save. + +D2/D11: `targetFetch(capture, input, init)` in gui/src/api.ts validates the immutable capture before entering installApiAuthFetch/authenticated fetch. Capture is created by the UI/hook action at click/save start, before any await. Its requestGeneration changes on a logical target transition, including A-B-A, not on a successful same-target refresh or session token rotation. The current target must still match the captured base/key/origin before authentication headers are attached. For negotiated relay support the header comes only from capture.origin. No caller can silently refresh its capture in a retry; an explicit new user action is needed after target change. Requests to legacy relay perform the documented fresh status preflight with the same original capture and then invoke authenticated fetch, with the acknowledged TOCTOU limit. + +Bound expected-target to 512 UTF-8 bytes; accept only a canonical HTTP(S) origin without userinfo/path/query/fragment and reject duplicate/comma-combined values. This bound applies only to the opt-in protocol field, not existing configured base URL storage. Reject before body read. Legacy notice appears once per page/target when attempting a relay mutation without negotiated capability: "This proxy checks the save target before sending, but cannot verify it atomically. Update the proxy for target-bound saves." Localize this intentional compatibility notice; it carries no key or credential. + +D7/D8/D11 call map: Providers.tsx, AddProviderModal.tsx, ProviderWorkspaceShell.tsx, use-dashboard-data.ts and useCodexAccountPool.ts pass logical identity to usageSummary30dResourceKey. Keep its default behavior for callers without a target context. Both Providers and AddProviderModal preset keys include identity. Update gui/tests/dashboard-contracts.test.ts source assertions to the actual contract and add behavioral tests; do not remove an invariant merely to change its spelling. Add usage summary resource, Add Provider modal and dashboard target-scope suites to focused validation. + +Browser fixture remap: run an isolated client machine listener on one fixed loopback port, initially configured for synthetic hub A; terminate and re-create that listener on the same port with synthetic hub B while leaving the browser and two hub fixtures running. Reconnect/response timing is controlled by fixture barriers, not sleeps alone. Verify the relay URL is byte-for-byte unchanged, the real App rediscovery updates identity/session readiness, and destinations recorded by A/B match the original action capture. Separately exercise the relay handler's immutable-snapshot seam with a controlled connection change between capture and forwarding. Same-target restart/key rotation must keep drafts; A-B-A must fence old callbacks while restoring the parked draft. + +## Build entry clarifications after revision-4 audit PASS + +- Include gui/src/pages/Dashboard.tsx as the App-to-useDashboardData targetKey wrapper. +- Lifecycle metadata is bounded to one current record per visited target/provider during the page lifetime, plus authority held by currently pending requests; there is no persistent store or arbitrary eviction that revives stale work. +- useApiTargets owns a single in-flight discovery plus request-generation comparison; event bursts coalesce, and same-target session refresh cannot recursively schedule discovery indefinitely. +- Dependency edges: discovery identity (1) precedes store lifecycle integration (3), consumer wiring (4), then acceptance/docs (5). Relay protocol (2) is independently implementable but integration (4) consumes its negotiated contract. One writer implements these in that order. +- Mutation capture also records transport; it is checked with base/key/origin/generation before current authentication is read. No late live-transport lookup may silently retarget the request. +- Settings conflicts concern concurrent saved values already observed by this dashboard. No atomic PATCH precondition is claimed. +- Fixed-port browser fixture restart creates fresh machine management auth state; the real App must re-bootstrap its machine session and reconcile shared readiness. No retained test token is injected to hide session recovery. + +Audit receipt: same independent reviewer task 8325-audit-plan-r4 concluded VERDICT: PASS, four earlier blockers closed. Seven nonblocking clarifications above are folded without changing the selected architecture. Final code reviews and actual E2E/CI remain outstanding. + +## Revision 5: capture ownership at the data owner and dialog boundary + +The e207 review found that the transport was fenced but the capture was created one +level too late. This revision records the corrected ownership, without changing the +selected architecture. + +- A rendered surface owns its capture. `useRenderTargetCapture(apiBase, targetKey)` + creates it on the render that last saw that target identity and replaces it only when + the identity changes. A module target change that has not re-rendered the surface + cannot retarget its next action. +- `onUpdateProvider(name, patch, capture?)` is the typed callback. The form passes its + render capture; `ProviderDetails` injects its own for Overview notes and the xAI + toggle; `useProvidersCrud.updateProvider` uses the passed capture and never replaces + it with a live one. Account mode captures before its confirmation dialog. +- Every mutation and read owner creates its capture at render: the account pool, + OAuth, Add Provider OAuth, Kiro, Codex pool, auto-switch and Anthropic pool hooks. + The logical target is threaded through ProviderAuthPanel, the Kiro dialog, the + Anthropic pool card and CodexAccountPool. A parameterized render-gap suite + exercises every owner. +- The Settings leave dialog captures its target when it opens, closes on a target + change, and uses the form owner-checked discard. +- A remove confirmation captures when it opens and refuses after a target change, + including A-B-A. Key remove/add/alias guard every completion. The add-key form and the + Add Provider completion do not survive a switch. Kiro polls with the session capture + and ends when it is stale. CodexSet threads the logical target. +- The render-owned capture changes only where the capture is created. Every stale + capture is still refused before authentication or fetch; there is no send exception. + After the identity check, the relay capability is read from the live target, so a + same-identity downgrade uses the fresh legacy preflight and a downgrade with a target + change refuses before fetch. This corrects an implementation drift from plan + revision 4; the architecture itself is unchanged. + diff --git a/devlog/_plan/261109_telemetry_personal_rank/000_plan.md b/devlog/_plan/261109_telemetry_personal_rank/000_plan.md new file mode 100644 index 00000000000..8eed879c8e3 --- /dev/null +++ b/devlog/_plan/261109_telemetry_personal_rank/000_plan.md @@ -0,0 +1,105 @@ +# Opt-in public profile and personal usage rank + +## Objective and scope + +Let a person who runs opencodex with anonymous telemetry on explicitly opt in to a **public profile** and +see where their own usage ranks: tokens, requests and model families over the last day, week and month, +on the dashboard Home and at `https://opencodex.me/dashboard`. Profiles are off by default, separate +from anonymous telemetry, revocable from the same card and from the CLI, and a disconnect deletes the +server-side profile and every row submitted for it. + +Delivery is one pull request stacked on `goat/telemetry-06-dashboard` (#8308), retargeted to `dev` once +the telemetry stack (#8300 → #8348 → #8303 → #8304 → #8305 → #8308) has landed. The stack belongs to +another lane; this unit never pushes to its branches and merges their updates in (no rebase). No +deploy of the collector, no D1 database, no OAuth app, no DNS change: those are maintainer steps. + +Prior art: [001_prior_art_tokscale.md](001_prior_art_tokscale.md). + +## Owner decisions (2026-11-09) + +| Id | Decision | +| --- | --- | +| scope | Supersedes the 261107 unit's "no per-user profiles or leaderboards" for **opt-in** profiles only. The anonymous dashboard stays as it is. | +| entry | The Home screen shows a Connect card while anonymous telemetry is effectively on (and whenever a profile is connected, so it can always be disconnected). | +| identity | v1 uses a chosen handle plus a server-issued secret. GitHub device-flow login is a follow-up: it needs a maintainer-created GitHub OAuth app (a cloud resource this unit may not create). No e-mail is ever asked for or shown. | +| fields | Public: handle, display name, avatar colour (closed palette, no image URLs), per-period totals and ranks, model-family totals. Nothing else. | +| cli | `ocx telemetry profile connect|disconnect|status`. | +| deploy | Collector code, schema and tests only. No cloud resources. | + +## Privacy contract (stack-lane gates, 2026-11-09) + +1. **Separate, explicit, human-only consent.** Connecting is never implied by the anonymous telemetry notice, + its decision, or its default. It needs a dashboard session that is a human session + (`hasHumanDashboardSession`) or an interactive TTY that is not agent-driven and not `OCX_SERVICE` + (same `agent_consent_required` / `tty_required` refusals as `ocx telemetry enable`), and an explicit + confirmation that defaults to **no**. +2. **No linkage to anonymous telemetry identifiers.** The profile never reads, derives from, sends or stores + `telemetry-id.json`, `installId`, `installDayHash` or `batchId`. Profile submissions go to different + endpoints on their own timer with independent jitter, carry a server-issued bearer secret, and contain no + version, platform, channel, provider kind or error field. The anonymous rollup tables keep no per-install + token or request totals, so a profile row cannot be joined back to an install by value. +3. **Independent state.** Declining or disabling anonymous telemetry never changes profile state, and connecting + or disconnecting a profile never changes the telemetry decision, notice or state file. Profile *sending* + additionally requires anonymous telemetry to be effective (env opt-outs, decline and `awaiting-notice` + pause it, without touching the profile), because the profile is built from the same local daily aggregate + and a person who turned telemetry off expects nothing to leave the machine. +4. **Aggregates only.** A profile day carries: the UTC day, total requests, five disjoint token buckets, and + per model family (closed `MODEL_FAMILIES` roster) requests and tokens. Never prompts, outputs, keys, + account ids, e-mails, provider names, base URLs, hostnames, paths or error text. +5. **No backfill.** Only completed UTC days strictly after the connect day are sent (client and collector both + enforce it), at most one day per tick, at most 7 days old, never a day inside an env opt-out period. +6. **Deletion.** Disconnect calls `DELETE /v1/profiles/me`; the collector deletes the profile row and all its + (Revocation is never gated: admin route and CLI always allowed, proxy running or not, anonymous telemetry on + or off.) + day rows in one batch. Until that succeeds the local file keeps the secret with `pendingDelete: true`, + sends nothing, and retries on every tick; status shows "deletion pending". +7. **Secret handling.** The secret is shown to nobody: never in API responses, CLI output, logs or the GUI. + Stored only in `/telemetry-profile.json` (atomic no-follow write, 0600; the collector stores + `sha256(secret)` only). +8. **Core path.** Nothing under `src/telemetry/profile/` is imported by `src/router.ts`, + `src/server/lifecycle.ts` or `src/server/responses/core.ts`; it loads lazily from background lifecycle, + the management dispatcher and the CLI. + +## Work phases (one PR, one commit series) + +| Goalplan | Doc | Thesis | +| --- | --- | --- | +| wp1 | this unit | Docs-first roadmap, audited. | +| wp2 | [010_client_core.md](010_client_core.md) | Profile contract, state file, collector client, sync tick, management routes, CLI, capability registry. | +| wp3 | [020_collector.md](020_collector.md) | D1 tables, profile + leaderboard endpoints, limits, abuse controls, pruning. | +| wp4 | [030_gui_card.md](030_gui_card.md) | Home Connect card with rank view and disconnect; 11 locales. | +| wp5 | [040_dashboard_leaderboard.md](040_dashboard_leaderboard.md) | docs-site leaderboard and personal-rank view; 8 locales. | +| wp6 | [050_docs_privacy.md](050_docs_privacy.md) | Guide, README + translations, code-signing pages, AGENTS_INSTALL, structure. | +| wp7 | [060_review_pr.md](060_review_pr.md) | Independent review, separate privacy review, PR, CI. | + +The contract module lands first (wp2) because the collector imports it (collector-first rollout applies to +any later change of it, as for the anonymous contract). + +## Verification floor + +`bun run typecheck`; focused tests per layer (failing first for every behaviour); `bun run test:changed`; +`bun run structure:check`; `bun run privacy:scan`; file-size ratchet, test-layout and Lab-boundary guards; +`bun run skill:surface:check` (new CLI capabilities); `bun run lint:gui` + `bun run build:gui` (wp4); +docs-site build (wp5/wp6); all in isolated homes (`env -i`, temp `HOME`/`OPENCODEX_HOME`/`CODEX_HOME`/ +`CLAUDE_CONFIG_DIR`). `bun run test` before review readiness unless resource contention makes it +impractical, recorded under Verification. + + +## Audit disposition (round 1) + +Independent audit (`mistral/deepseek-v4-1-flash-1017`, read-only; the first two routes failed with HTTP 401 and +stalled): NEAR-PASS, 1 blocker, 4 majors, 6 minors. + +| # | Finding | Disposition | +| --- | --- | --- | +| 1 | Disconnect admin-gated; can stay pending with no retry owner | Partly rebutted, partly folded. Revocation stays ungated, like `PUT /api/telemetry` declines and `ocx telemetry disable`: the human-only rule covers giving consent, and gating withdrawal would make it harder to leave. Folded: an explicit `deletion_pending` status with a named retry owner, the CLI deleting directly when the proxy is stopped, the loop starting whenever a profile file exists, deletion never gated on telemetry, and tests for all of these. | +| 2 | Create rate limit shares the anonymous IP-hash key | Folded: domain-separated hash input and a separate table, so no stored value is joinable. | +| 3 | Rank semantics undefined | Folded: rank contract in 020 (`RANK()`, handle display order, `rank: null`, `of`). | +| 4 | Plaintext secret, no rotation | Folded as documentation: disconnect + reconnect is the rotation path; the file is uninstall-owned and kept out of exports and backups (checked in B). | +| 5 | Public profile indexability/fields | Folded: `x-robots-tag: noindex`, a fixed by-handle field list, no per-profile static page. | +| 6 | Loop does not start under env opt-out | Folded with 1. | +| 7 | CLI usage strings | Folded: `USAGE` and the registry usage change in the same commit. | +| 8 | Dedup SQL unspecified | Folded: exact pattern written in 020. | +| 9 | GUI rendering/link encoding | Folded in 030. | +| 10 | README wording ambiguity | Folded in 050. | +| 11 | `--no-verify` unnecessary | Rebutted: the owner's merge rules require pushing with `--no-verify` and disclosing it. | diff --git a/devlog/_plan/261109_telemetry_personal_rank/001_prior_art_tokscale.md b/devlog/_plan/261109_telemetry_personal_rank/001_prior_art_tokscale.md new file mode 100644 index 00000000000..60f3b4e1853 --- /dev/null +++ b/devlog/_plan/261109_telemetry_personal_rank/001_prior_art_tokscale.md @@ -0,0 +1,33 @@ +# Prior art: tokscale leaderboard + +Source: [junhoyeo/tokscale](https://github.com/junhoyeo/tokscale) (MIT) README, its +[privacy policy](https://tokscale.ai/privacy) and [leaderboard](https://tokscale.ai/leaderboard), read 2026-11-09. + +## UX + +- Local stats work without any account. Joining the leaderboard is a separate, explicit action: + `tokscale login` (GitHub OAuth), `tokscale whoami`, `tokscale submit`; `submit --dry-run` previews the upload. +- Headless/CI submission uses an API token created in the web settings (`TOKSCALE_API_TOKEN`). +- Public profile page `/u/`, SVG embed and badge (`metric=tokens|cost|rank`). +- Settings can delete submitted data or the whole account, immediately. + +## Data model + +- Submission: `POST /api/submit` with aggregate usage. Server validation: required fields, mathematical + consistency, non-negative values, no future dates, duplicate detection. +- Uploaded: token counts, estimated cost, per-day breakdowns, clients and models, message counts, session timing, + MCP server names, CLI version, device labels. +- Public: username, avatar, display name, totals, contribution graph, client/model breakdowns, devices, groups; + indexable by search engines. OAuth scopes `read:user`, `user:email`. + +## What opencodex adopts and what it does not + +| tokscale | opencodex profile v1 | +| --- | --- | +| Explicit login + submit, dry run | Explicit connect (human-only), status shows the exact next submission (`preview`). | +| Server validation (consistency, non-negative, no future, dedup) | Same, plus closed family roster, per-day plausibility caps, first-write-wins per (profile, day), no-backfill window. | +| GitHub identity, e-mail scope | Handle + server secret; no e-mail anywhere. GitHub device flow deferred (needs an OAuth app). | +| Public devices, session timing, MCP names, cost | Not collected. Only day totals, token buckets and model-family totals. | +| Avatar from GitHub | Closed colour palette, initials; no image URLs (no tracking pixels, no moderation surface). | +| Delete data/account | Disconnect deletes the profile and all rows; retried until confirmed. | + diff --git a/devlog/_plan/261109_telemetry_personal_rank/010_client_core.md b/devlog/_plan/261109_telemetry_personal_rank/010_client_core.md new file mode 100644 index 00000000000..e49c0e48564 --- /dev/null +++ b/devlog/_plan/261109_telemetry_personal_rank/010_client_core.md @@ -0,0 +1,183 @@ +# 010 — Client core: contract, state, sync, routes, CLI (wp2) + +All new code lives under `src/telemetry/profile/` (owned by `structure/telemetry.md`). Edits to stack-owned files +are additive and listed explicitly. + +## NEW `src/telemetry/profile/contract.ts` (import-free except `../contract`) + +The collector imports it, like `src/telemetry/contract.ts`. + +```ts +import { MODEL_FAMILIES, parseCalendarUtcDayMs, type TelemetryModelFamily } from "../contract"; +export const PROFILE_SCHEMA_VERSION = 1 as const; +export const PROFILE_HANDLE_RE = /^[a-z0-9](?:[a-z0-9-]{1,22})[a-z0-9]$/; // 3–24, no leading/trailing "-" +export const PROFILE_RESERVED_HANDLES: readonly string[]; // admin, api, me, ocx, opencodex, root, support, system, telemetry, leaderboard, dashboard, anthropic, openai, google, null, undefined, … +export const PROFILE_AVATARS = ["slate","red","orange","amber","lime","green","teal","cyan","blue","indigo","violet","pink"] as const; +export const PROFILE_DISPLAY_NAME_MAX = 32; +export const PROFILE_MAX_DAY_REQUESTS = 1_000_000; +export const PROFILE_MAX_DAY_TOKENS = 10_000_000_000; +export const PROFILE_SUBMIT_MAX_AGE_DAYS = 7; +export const PROFILE_SECRET_RE = /^ocxp_[A-Za-z0-9_-]{43}$/; // 32 random bytes, base64url +export type ProfileAvatar = typeof PROFILE_AVATARS[number]; +export type ProfilePeriod = "day" | "week" | "month"; +export const PROFILE_PERIOD_DAYS = { day: 1, week: 7, month: 30 } as const; +export interface ProfileTokensV1 { input; cacheRead; cacheWrite; output; reasoning } // non-negative safe ints +export interface ProfileFamilyRowV1 { family: TelemetryModelFamily; requests: number; tokens: number } +export interface ProfileDayV1 { schemaVersion: 1; day: string; requests: number; tokens: ProfileTokensV1; families: ProfileFamilyRowV1[] } +export interface ProfileCreateV1 { schemaVersion: 1; handle: string; displayName: string; avatar: ProfileAvatar } +export function normalizeProfileHandle(raw: string): string; // trim + lowercase +export function validateProfileHandle(raw: unknown): { ok: true; handle } | { ok: false; error }; +export function validateProfileDisplayName(raw: unknown): { ok: true; displayName } | { ok: false; error }; +export function validateProfileCreateV1(input: unknown): { ok: true; value } | { ok: false; error }; +export function validateProfileDayV1(input: unknown, now: number): { ok: true; value } | { ok: false; error }; +export function sumProfileTokens(t: ProfileTokensV1): number; +``` + +Validation rules (shared by client and collector): + +- Exact key sets at every level (unknown keys rejected). +- Handle: `PROFILE_HANDLE_RE`, no `--`, not all digits, not reserved. +- Display name: NFC, trimmed, 1–32 code points; only Unicode letters, marks, numbers, space and `. _ - '`; + no two consecutive spaces; rejects anything matching `/\.[a-z]{2,}/i` (domain-like) so a name cannot be a link. +- Day: calendar `YYYY-MM-DD`, strictly before today (UTC), at most `PROFILE_SUBMIT_MAX_AGE_DAYS` old. +- `requests` ≤ `PROFILE_MAX_DAY_REQUESTS`, token sum ≤ `PROFILE_MAX_DAY_TOKENS`. +- Families: unique, from `MODEL_FAMILIES`, `requests > 0`, sum of family requests == `requests`, sum of family tokens + == token sum, a row with tokens > 0 needs requests > 0. `requests == 0` days are not sent. + +## NEW `src/telemetry/profile/state.ts` + +File `/telemetry-profile.json`, written with `atomicWriteFileNoFollow` and mode 0600 (chmod after +write on POSIX, like `telemetry-state.json`), read with a 64 KiB cap and `lstat` symlink refusal. + +```ts +export const TELEMETRY_PROFILE_FILE = "telemetry-profile.json"; +export interface TelemetryProfileState { + schemaVersion: 1; handle; displayName; avatar; secret; // secret never leaves this module except to client.ts + connectedAt: string; connectedDay: string; // UTC day of connect; only later days are sent + sentDays: Record; lastSentDay: string | null; lastSentAt: string | null; + retryAfterUntil: string | null; pendingDelete: boolean; revoked: boolean; +} +export function readTelemetryProfileFile(dir?): { state: TelemetryProfileState | null; unreadable: boolean }; +export function writeTelemetryProfileState(state, dir?): void; // prunes sentDays older than 30 days +export function deleteTelemetryProfileFile(dir?): void; +export function toPublicProfileStatus(state): TelemetryProfilePublicState; // drops secret +``` + +## NEW `src/telemetry/profile/client.ts` + +`TELEMETRY_PROFILE_API_BASE = "https://telemetry.opencodex.me"` (constant, not configurable; test override seam like +`setTelemetrySenderOverridesForTests`). Each call uses `signalWithTimeout(10_000)` and returns a tagged result +`{ ok: true, body } | { ok: false, status?, error, retryAfterMs? }`: + +- `createProfile(body: ProfileCreateV1)` → `POST /v1/profiles`, 201 `{ ok, profile, secret }` (secret validated with `PROFILE_SECRET_RE`). +- `submitProfileDay(secret, day: ProfileDayV1)` → `POST /v1/profiles/me/days` (`authorization: Bearer`). +- `fetchOwnProfile(secret)` → `GET /v1/profiles/me`. +- `deleteProfile(secret)` → `DELETE /v1/profiles/me`; 200 and 401 both count as deleted (401 = the secret is unknown, the row is already gone). + +## NEW `src/telemetry/profile/sync.ts` + +```ts +export function buildProfileDay(aggregate: DailyTelemetryAggregate): ProfileDayV1 | null; // sums rows by family; null when no requests +export function resolveNextProfileDay(state, telemetryState, now): { day: string | null; reason }; +export async function runProfileTick(options): Promise<{ sent: boolean; deleted?: boolean; day?: string; reason: string }>; +export function startProfileScheduler(): () => void; // startup 2–10 min jitter, then 6 h ± 30 min; unref'd +export function nudgeProfileScheduler(): void; +``` + +Tick order: (1) unreadable profile file → `state-unreadable`; none → `not-connected`. (2) `pendingDelete` → `deleteProfile`, +on success delete the file → `deleted`. (3) `revoked` → `revoked` (nothing sent until reconnect). +(4) Anonymous telemetry gate: `resolveTelemetryStatus(withPendingTelemetryDecision(diskConfig))` must be effective and +`isTelemetryEnvOptOut` null; `telemetry-state.json` must be readable. Otherwise `telemetry-off` / `state-unreadable` +(the profile file is not modified). (5) `retryAfterUntil` in future → `retry-after-backoff`. +(6) Day: reuse `resolveNextEligibleTelemetryDay(status, { ...telemetryState, sentDays: profile.sentDays, +ineligibleThroughDay: max(telemetryState.ineligibleThroughDay, profile.connectedDay) }, now)` so every anonymous +eligibility rule applies unchanged (strictly after the telemetry consent day, nothing while an env opt-out marker or +owner is set, strictly after `ineligibleThroughDay`, 7-day window) plus strictly after the connect day and not +already in the profile's own `sentDays`. (7) Aggregate with `aggregateTelemetryForDay(d)`, build the day; a day with no requests is recorded +as sent without a request. (8) Re-check the gates just before sending. (9) Send; 200 → record; 401 → `revoked = true`; +429/503 → `retryAfterUntil`; other → `send-failed`. + +The profile tick never reads `telemetry-id.json` and never imports `install-id.ts` (guarded by a source test). + +## NEW `src/telemetry/profile/routes.ts` + MODIFY `src/server/management/telemetry-routes.ts` + +`telemetry-routes.ts` gains a lazy branch: paths under `/api/telemetry/profile` go to +`import("../../telemetry/profile/routes")`. Routes: + +| Method | Path | Guard | Behaviour | +| --- | --- | --- | --- | +| GET | `/api/telemetry/profile` | admin | `{ available, connected, profile?: {handle, displayName, avatar, connectedAt, lastSentDay, pendingDelete, revoked}, telemetryEffective, nextDay, preview }`. `preview` is the exact `ProfileDayV1` the next tick would send (or null). Never the secret. | +| GET | `/api/telemetry/profile/rank` | admin | Proxies `GET /v1/profiles/me`, cached 5 min per handle; `{ ok, rank }` or `{ ok: false, error }`. | +| POST | `/api/telemetry/profile` | human session (`hasHumanDashboardSession`), else 403 `agent_consent_required` | Body `{ handle, displayName, avatar, acknowledgePublic: true }`. Refuses with 409 `telemetry_off` unless anonymous telemetry is effective, 409 `already_connected`. Creates the profile, writes the file, nudges the scheduler. Maps collector 409 → `handle_taken`, 400 → `invalid`, 429 → `rate_limited`. | +| DELETE | `/api/telemetry/profile` | admin (revocation is never gated: parity with `PUT /api/telemetry` declines and `ocx telemetry disable`, which are always permitted; only *connecting* is human-only) | Sets `pendingDelete`, attempts deletion immediately (not gated on anonymous telemetry being on), returns `{ ok, deleted, status: "deleted" \| "deletion_pending" }`. `deletion_pending` names the retry owner: the proxy's profile loop on every tick and every start. | + +MODIFY `src/server/management/route-registry.ts`: four rows; POST is `exempt: { reason: "session-only", … }` like +`PUT /api/telemetry`. + +## NEW `src/cli/telemetry-profile.ts` + MODIFY `src/cli/telemetry.ts` + +`ocx telemetry profile [status] [--json]`, `ocx telemetry profile connect --handle [--name ] [--avatar ] [--json]`, +`ocx telemetry profile disconnect [--json]`. `telemetry.ts` dispatches `sub === "profile"` before the `enable` gate. +`connect` refuses `isAgentDriven()` (`agent_consent_required`) and non-TTY / `OCX_SERVICE` (`tty_required`), requires +effective telemetry (`telemetry_off`), prints the public-profile disclosure (`PROFILE_DISCLOSURE_POINTS` in +`src/telemetry/profile/disclosure.ts`) and asks `[y/N]` (default no). `disconnect` is always allowed. +`status` reads the live route and falls back to local state. + +MODIFY `src/cli/telemetry.ts` `USAGE`, `src/cli/registry.ts` (usage string `ocx telemetry [--json]` + detail lines, same commit as the dispatch), `src/cli/capabilities-observe-system.ts` (three +capability literals), then `bun run skill:surface` regenerates `skills/ocx/references/*`. + +## MODIFY `src/server/background-lifecycle.ts` + +Independently of the anonymous telemetry env gate (a profile awaiting deletion must be retried even under +`OCX_TELEMETRY=0`, because deletion is the user's own request and sends only the bearer secret), lazily +`import("../telemetry/profile/sync").then(m => stopProfileLoop = m.startProfileScheduler())` **only when +`telemetry-profile.json` exists** (an `existsSync` check; installs without a profile load no profile code); stop it +in the same stop path. Connecting through the management route starts the loop if it is not running. Only the +*send* step is gated on anonymous telemetry. No other stack file changes. + +## CLI disconnect without a running proxy + +`ocx telemetry profile disconnect` first tries the live route; when the proxy is not reachable it performs the +same steps itself (mark `pendingDelete`, call `deleteProfile`, delete the file on success) and prints +`deletion_pending` with "retried by the proxy on its next start" when the collector cannot be reached. + +## Secret rotation and exposure + +v1 has no rotate endpoint: disconnect + reconnect is the rotation path (documented). `telemetry-profile.json` is +recorded with `recordOwnedConfigPath` (uninstall removes it) and must not appear in any config export or desktop +backup set (checked in B against `ocx config export` and the desktop backup metadata list). + +## Tests (failing first) + +- NEW `tests/usage/usage-telemetry-profile-contract.test.ts`: handle/name/day/family validation, sums, caps. +- NEW `tests/usage/usage-telemetry-profile-sync.test.ts`: no send when telemetry off/env opt-out/awaiting notice/unreadable + state (and profile file untouched); no backfill (connect day and earlier never sent); oldest-first one per tick; + pendingDelete retries and deletes the file; 401 revokes; secret absent from `toPublicProfileStatus`; the module graph + of `sync.ts` excludes `install-id.ts` and the payload never contains `installDayHash`/`batchId`. +- NEW `tests/server/server-telemetry-profile-routes.test.ts`: session gate, telemetry_off, no secret in any body, + DELETE pending path, telemetry decision unchanged by connect/disconnect. +- NEW `tests/cli/cli-telemetry-profile.test.ts`: agent/TTY refusals, default-no prompt, disconnect always allowed. + Disconnect with the proxy stopped deletes directly; with the collector unreachable it leaves `pendingDelete` and + the next proxy start's tick deletes it, including while `OCX_TELEMETRY=0` or telemetry is declined. +- MODIFY `tests/usage/usage-telemetry-core-boundary.test.ts` (or a sibling file if it is near a cap): the three core + files must not reach `src/telemetry/profile/`, and `src/telemetry/profile/sync.ts` must not reach + `src/telemetry/install-id.ts`. +- Each new test file is registered in `scripts/test-layout/layout*.json` and `tests/fixtures/test-layout-expected*.json`. + + +## Stale check (wp2 P, 2026-11-09) + +- Route path equalities must sit in `src/server/management/telemetry-routes.ts` (the route registry reconciles by an + equality scan of the owning module), with the handlers in `src/telemetry/profile/routes.ts` behind a lazy import. +- `tests/usage/usage-telemetry-core-boundary.test.ts` already forbids any load-time path from the core files and + `management-api.ts` into `src/telemetry/`, which covers `src/telemetry/profile/`. The new install-id isolation + check goes into the profile sync test. +- `background-lifecycle.ts` statically imports `src/telemetry/state.ts` and `consent.ts`; its profile file check + imports only a tiny `src/telemetry/profile/paths.ts` (no I/O at module load). +- `withPendingTelemetryDecision`, `readTelemetryStateFile`, `formatUtcDay` and `utcMidnightMs` are exported from + `state.ts` (re-exported by `scheduler.ts`); the profile code imports `state.ts` and `consent.ts` directly, and + `resolveNextEligibleTelemetryDay`/`latestCompletedUtcDay` from `scheduler.ts`, which statically imports + `install-id.ts`. A-phase decision: do not move stack-owned code (the stack lane may still edit `scheduler.ts`); + instead the profile test scans every file under `src/telemetry/profile/` and fails if one imports `install-id`, + `payload` or `sender`, or names `installDayHash`, `batchId`, `installId` or `telemetry-id`, and asserts the + submitted body's exact key set. diff --git a/devlog/_plan/261109_telemetry_personal_rank/020_collector.md b/devlog/_plan/261109_telemetry_personal_rank/020_collector.md new file mode 100644 index 00000000000..23a8fda0ab0 --- /dev/null +++ b/devlog/_plan/261109_telemetry_personal_rank/020_collector.md @@ -0,0 +1,102 @@ +# 020 — Collector: profiles and leaderboard (wp3) + +Extends `services/telemetry-collector/`; the anonymous ingest/stats code and its tables are untouched. + +## MODIFY `schema.sql` (append) + +```sql +CREATE TABLE IF NOT EXISTS profiles ( + id TEXT PRIMARY KEY, -- random UUID, never shown + handle TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + avatar TEXT NOT NULL, + secret_hash TEXT NOT NULL UNIQUE, -- sha256(secret) hex + created_day TEXT NOT NULL, + last_submit_day TEXT, + hidden INTEGER NOT NULL DEFAULT 0 -- maintainer moderation flag (wrangler d1 execute) +); +CREATE TABLE IF NOT EXISTS profile_days ( + profile_id TEXT NOT NULL, day TEXT NOT NULL, + requests INTEGER NOT NULL, input INTEGER NOT NULL, cache_read INTEGER NOT NULL, + cache_write INTEGER NOT NULL, output INTEGER NOT NULL, reasoning INTEGER NOT NULL, tokens INTEGER NOT NULL, + PRIMARY KEY (profile_id, day) +); +CREATE INDEX IF NOT EXISTS profile_days_day ON profile_days (day); +CREATE TABLE IF NOT EXISTS profile_day_families ( + profile_id TEXT NOT NULL, day TEXT NOT NULL, family TEXT NOT NULL, + requests INTEGER NOT NULL, tokens INTEGER NOT NULL, + PRIMARY KEY (profile_id, day, family) +); +CREATE TABLE IF NOT EXISTS profile_rate_limits ( + bucket_hour TEXT NOT NULL, scope TEXT NOT NULL, key_hash TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (bucket_hour, scope, key_hash) +); +``` + +## NEW `src/profiles.ts` (write paths) and `src/leaderboard.ts` (read paths) + +| Method | Path | Auth | Limit (per hour) | Behaviour | +| --- | --- | --- | --- | --- | +| POST | `/v1/profiles` | none | 5 per client key (`create`) | `validateProfileCreateV1`; body ≤ 2 KiB; 409 `handle taken` (unique constraint); 201 `{ ok, profile: {handle, displayName, avatar, createdDay}, secret }`. Secret = `ocxp_` + base64url(32 random bytes); only its hash is stored. | +| POST | `/v1/profiles/me/days` | bearer | 48 per profile (`submit`) | `validateProfileDayV1`; `day > created_day` (no backfill, 400); first write wins per (profile, day), the same pattern as ingest (`ingest.ts` dedup): one atomic `batch` of `INSERT INTO profile_day_families … SELECT … WHERE NOT EXISTS (SELECT 1 FROM profile_days WHERE profile_id = ?1 AND day = ?2)` rows, then `INSERT OR IGNORE INTO profile_days …` last; `deduplicated = meta.changes === 0` of the last statement; `UPDATE profiles SET last_submit_day = max(…)`. | +| GET | `/v1/profiles/me` | bearer | 120 per profile (`me`) | `{ ok, profile, hidden, periods: {day,week,month: {rank, of, tokens, requests}}, families: [{family, tokens, requests}] }` (families over month). Rank contract below. | +| DELETE | `/v1/profiles/me` | bearer | 10 per client key (`delete`) | Deletes families, days, then the profile in one batch → `{ ok, deleted: true }`; unknown secret → 401. | +| GET | `/v1/leaderboard?period=day|week|month&metric=tokens|requests&limit=1..100` | none | 600 per client key (`read`) | Rank contract below; `{ schemaVersion: 1, period, metric, from, to, generatedAt, total, entries: [{rank, handle, displayName, avatar, tokens, requests}] }`. Amended in wp7: no in-process cache (a hide applies on the next read), `cache-control: public, max-age=60`. | +| GET | `/v1/profiles/by-handle/` | none | shares `read` | Exactly `{ ok, profile: {handle, displayName, avatar}, periods, families }`; never `created_day`, `last_submit_day`, `hidden` or ids; 404 for unknown or hidden. | + +**Rank contract.** Ranked set for (period, metric) = non-hidden profiles whose metric sum over the window is > 0. +`rank` = SQL `RANK() OVER (ORDER BY metric DESC)` (ties share a rank, the next rank skips); display order within a +tie is `handle ASC`. `of` = size of the ranked set. A profile outside the ranked set (no positive metric, or hidden) +gets `rank: null` and `of` unchanged (`0` when nobody is ranked). The GUI, docs-site and CLI all read these +fields and never compute ranks themselves. + +**No indexing.** Every profile and leaderboard response carries `x-robots-tag: noindex`; the docs-site +personal-rank view is client-rendered from a query parameter, so no per-profile page exists for crawlers. + +Bearer parsing: `authorization: Bearer `, `PROFILE_SECRET_RE`, else 401 without touching D1. +Period window: anchor = latest completed UTC day; `from = anchor − (days − 1)`. +Rate limits reuse `rateLimitClientKey` but never the anonymous ingest hash value: client keys are hashed as +`computeHourlyIpHash(salt, "profile:" + scope + "\0" + clientKey, hour)` and stored only in `profile_rate_limits`, so no +stored value equals an `ip_hash` in `rate_limits` and the two tables cannot be joined; profile-scoped limits hash +the profile id with the same salt. 429 with `Retry-After` like ingest. Missing `RATE_LIMIT_SALT` → 503. + +## MODIFY `src/index.ts` + +Route the paths above (405 with `allow` for wrong methods, preflight for GETs only; CORS stays +`opencodex.me` + localhost; write endpoints are called by the proxy, not by browsers, so no +`authorization` in `access-control-allow-headers`). + +## MODIFY `src/prune.ts` + +Also delete: `profile_rate_limits` older than 2 h; `profile_days`/`profile_day_families` older than 400 days; +profiles that never submitted and were created more than 14 days ago (handle squatting) together with nothing else; +profiles whose `last_submit_day` is older than 400 days with their rows. + +## Abuse controls (summary) + +Reserved handles, display-name charset with no links, closed avatar palette, create limit per client key, +per-profile submit limit, per-day plausibility caps and consistency checks, first-write-wins per day, no backfill, +squatting prune, maintainer `hidden` flag honoured by every public read. Documented in the collector README with +the `wrangler d1 execute` command for hiding a profile. + +## Tests (failing first) + +NEW `tests/usage/usage-telemetry-profile-collector.test.ts` using `createInMemoryD1`: create/duplicate/reserved/ +invalid; secret format and only hash stored; submit validation, no backfill, dedup, caps; me/periods/ranks/ties; +hidden excluded from leaderboard and by-handle; delete removes every row; rate limits per scope with Retry-After; +prune rules; leaderboard rows contain only the allowed keys. Extend the collector typecheck test's file list if it +enumerates files. + + +## Stale check (wp3 P, 2026-11-09) + +- `readBoundedBody` in `ingest.ts` is private and its error text names 16 KiB; profiles get their own small bounded + reader (2 KiB create, 8 KiB day) instead of editing the stack-owned ingest module. +- Caching: an isolate cache keyed by canonical query (5 min) plus `cache-control: public, max-age=300` on public reads; + the Workers edge-cache API is left out (the stack lane is changing its error handling in `stats.ts`). + Bearer responses are `no-store`. +- Handle uniqueness uses `INSERT OR IGNORE` + `meta.changes` (no reliance on constraint exceptions, which differ + between `bun:sqlite` and D1). +- Write endpoints get no CORS preflight (OPTIONS → 405), so a browser page cannot send the bearer header cross-origin. +- `tests/usage/usage-telemetry-collector-worker.test.ts` dumps a fixed list of anonymous tables; the new profile test + dumps the profile tables the same way and checks they never contain an IP, a raw secret or an anonymous identifier. diff --git a/devlog/_plan/261109_telemetry_personal_rank/030_gui_card.md b/devlog/_plan/261109_telemetry_personal_rank/030_gui_card.md new file mode 100644 index 00000000000..95a4fbe263f --- /dev/null +++ b/devlog/_plan/261109_telemetry_personal_rank/030_gui_card.md @@ -0,0 +1,63 @@ +# 030 — Dashboard Home Connect card (wp4) + +## NEW `gui/src/telemetry-profile-api.ts` + +Typed client for `/api/telemetry/profile` (GET/POST/DELETE) and `/rank` with `createBoundedFetch`, parsers that drop +unknown fields, and `TELEMETRY_PROFILE_CHANGED_EVENT`. Errors carry the server `code` +(`agent_consent_required`, `telemetry_off`, `handle_taken`, `invalid`, `rate_limited`). + +## NEW `gui/src/components/telemetry-profile-card.tsx` + +Rendered by `Home.tsx` below the stat tiles (one import + one element). Hidden when the GUI runs as a Remote Link +Child, or when `available` is false, or when telemetry is not effective **and** no profile is connected. + +States: + +1. **Not connected** — title "Public profile & rank", one paragraph naming exactly what becomes public (handle, display + name, colour, daily totals of requests and tokens, model families) and what never leaves (prompts, keys, accounts, + e-mail, devices), a link to the guide. Collapsed form behind a "Connect" button: handle, display name, avatar colour + swatches, an unchecked checkbox "I understand this profile is public", Connect (disabled until valid and checked). + Errors map to localized messages; `agent_consent_required` says to use the dashboard in a normal session or the CLI. +2. **Connected** — avatar initial on the chosen colour, display name, @handle; three tiles Day / Week / Month with + rank "#n of m", tokens (compact) and requests; top three model families for the month; "First rank appears after your + first full UTC day" when nothing is ranked yet; "View leaderboard" link to + `https://opencodex.me/dashboard/?profile=#leaderboard`; Disconnect (confirm step: "Delete your public profile + and its data?"). +3. **Deletion pending** — message plus Retry. +4. **Paused** — connected but telemetry off: "Paused while anonymous telemetry is off" (no auto-disconnect). + +## Copy + +User-supplied strings render as React text only (no `dangerouslySetInnerHTML`); the leaderboard link builds the +query with `URLSearchParams` and only for a handle that passes `PROFILE_HANDLE_RE` (test with `a-1-b2`). + +NEW `gui/src/i18n/v2-pages/telemetry-profile-copy.ts` (`copyTable`, 11 columns like `proxy-copy.ts`), spread into each +locale catalogue the same way `proxyV2Copy` is. + +## Tests + +NEW `gui/tests/telemetry-profile-card.test.tsx`: visibility rules, connect disabled until checkbox, request body +shape, connected rank rendering, disconnect confirm + DELETE, pending state, Remote Link Child hidden. Screenshot of +both states for the PR via the `pr-assets` branch. + + +## Implementation record (wp4, eab9d1e49a) + +- Process deviation: the card was written while the loop FSM was still at P; the A/B edges were advanced afterwards + with the deviation stated in the attests. The plan doc had been audited in wp1, so the code follows an audited plan. +- `gui/src/telemetry-profile-api.ts` owns the wire parsing and `isValidProfileHandle` (moved out of the component + file because oxlint's `react(only-export-components)` rule rejects non-component exports there). +- The card hides itself when `/api/telemetry/profile` is refused, which is how a Remote Link Child behaves (the path is + not link-readable), as well as under `isConnectedRuntime()`. +- Avatar colours are a fixed hex map (no Tailwind classes generated from data). +- Screenshots for the PR (kept out of the branch): the empty connect form, and the connected state before any + collector exists ("Rank is unavailable right now"), taken from a throwaway proxy in an isolated home. +- Verification: `gui/tests/telemetry-profile-card.test.tsx` (8 tests), full GUI suite 4109 pass, `lint:gui` and + `build:gui` clean. + +## Follow-up (wp4 second cycle) + +The card refreshes on `TELEMETRY_STATUS_CHANGED_EVENT` (dispatched by `putTelemetryDecision` in +`gui/src/telemetry-api.ts`), so switching anonymous telemetry off in Proxy details shows the paused state, or hides the +card, without waiting for the 60-second poll. Test: dispatch the event after the server state changes and assert +the card follows. diff --git a/devlog/_plan/261109_telemetry_personal_rank/040_dashboard_leaderboard.md b/devlog/_plan/261109_telemetry_personal_rank/040_dashboard_leaderboard.md new file mode 100644 index 00000000000..8bd94f1f6fc --- /dev/null +++ b/devlog/_plan/261109_telemetry_personal_rank/040_dashboard_leaderboard.md @@ -0,0 +1,38 @@ +# 040 — docs-site leaderboard and personal rank (wp5) + +## NEW `docs-site/src/components/telemetry-dashboard/leaderboard.ts` + +Fetches `GET /v1/leaderboard` (`TELEMETRY_LEADERBOARD_ENDPOINT` in `constants.ts`, overridable through +`data-leaderboard-endpoint` like the stats endpoint) and `GET /v1/profiles/by-handle/`. Renders with +`textContent` only (no `innerHTML` for user-supplied strings), avatar as an initial on a palette colour class. + +## MODIFY `TelemetryDashboard.astro` + +New `
`: period tabs (Day/Week/Month), metric toggle (Tokens/Requests), table +(rank, avatar+name, @handle, tokens, requests), an empty state ("No public profiles yet — connect from the opencodex +dashboard Home"), and a "Find a profile" form. `?profile=` (validated with `PROFILE_HANDLE_RE`) opens the +personal-rank view: rank per period, totals, model families, and a note that profiles are opt-in and self-reported. + +## MODIFY `telemetry-dashboard-i18n.ts` + +New keys in all 8 locales. + +## Tests + +NEW `tests/ci-workflows/docs-telemetry-leaderboard.test.ts` (source oracle like `docs-telemetry-dashboard.test.ts`): +every locale has the keys, user strings are set through `textContent`, handle validation before fetch, endpoints +constant. Docs-site build in C. + + +## Stale check (wp5 P, 2026-11-09) + +- `TelemetryDashboard.astro` (1521 lines, stack-owned, scoped styles) and `telemetry-dashboard-i18n.ts` (960 lines, + whose test forbids "opt-in" wording because the anonymous telemetry is on by default) stay untouched. The leaderboard + is a sibling component `docs-site/src/components/TelemetryLeaderboard.astro` mounted by `dashboard.mdx` after + ``, with its own copy module `docs-site/src/data/telemetry-leaderboard-i18n.ts` (same 8 + locales) and its own client `docs-site/src/components/telemetry-dashboard/leaderboard.ts` (endpoint constants live + there, not in the stack's `constants.ts`). +- The docs-site does not import from the root `src/`; the handle regex and palette are duplicated and a source-oracle + test asserts they equal `src/telemetry/profile/contract.ts`. +- Test: `tests/ci-workflows/docs-telemetry-leaderboard.test.ts` (pure helpers, copy parity, `textContent`-only rendering, + handle validation before any fetch, mdx mount). diff --git a/devlog/_plan/261109_telemetry_personal_rank/050_docs_privacy.md b/devlog/_plan/261109_telemetry_personal_rank/050_docs_privacy.md new file mode 100644 index 00000000000..7c09bae93c4 --- /dev/null +++ b/devlog/_plan/261109_telemetry_personal_rank/050_docs_privacy.md @@ -0,0 +1,14 @@ +# 050 — Docs and privacy statements (wp6) + +| File | Change | +| --- | --- | +| `docs-site/src/content/docs/guides/telemetry.md` | New section "Public profile and rank (opt-in)": what is public, what is sent, how to connect/disconnect (card + CLI), no linkage to the install id, deletion, rate limits, hidden flag, GitHub login as a follow-up. | +| `README.md` + 7 `readme/README.*.md` | Privacy line: amend "Project-wide aggregates are public." to "Only project-wide aggregates of this anonymous data are public." and add: "A separate public leaderboard profile is off unless you connect one yourself; it shares only a chosen handle, display name and daily totals, and disconnecting deletes it." (translated). Update `readme/i18n-manifest.json` in the same commit. | +| `docs-site/src/content/docs/reference/code-signing.md` + Turkish page | Same sentence. | +| `SPONSORS.md` | Check the telemetry clause; add that public profiles are opt-in if it lists telemetry data. | +| `AGENTS_INSTALL.md` | Connecting a public profile is a user-consent action an agent must never perform or answer. | +| `structure/telemetry.md` | New "Public profiles" section: modules, consent boundary, sync rules, collector endpoints and tables. | +| `services/telemetry-collector/README.md` | Endpoints, tables, limits, hide command. | + +Privacy review of this wording is separate (wp7). Installer unchanged: the profile is opt-in. + diff --git a/devlog/_plan/261109_telemetry_personal_rank/060_review_pr.md b/devlog/_plan/261109_telemetry_personal_rank/060_review_pr.md new file mode 100644 index 00000000000..89cd6c0212a --- /dev/null +++ b/devlog/_plan/261109_telemetry_personal_rank/060_review_pr.md @@ -0,0 +1,23 @@ +# 060 — Review, privacy review, PR, CI (wp7) + +1. Issue: feature proposal via `.github/ISSUE_TEMPLATE/feature_request.yml` (headings exact). +2. Independent code review (DeepSeek task, fresh) on the full diff vs `origin/goat/telemetry-06-dashboard`; fold + P0/P1 and correctness/security P2; re-review on a fresh task before merge. +3. Separate privacy/security review (DeepSeek task) focused on: linkage to install identifiers, secret exposure, + consent bypass by agents, data minimisation, deletion completeness, collector abuse. Notes in `.tmp/` only. +4. PR against `goat/telemetry-06-dashboard` with the template (Summary, Verification, Checklist), `Closes #N`, + screenshots via `pr-assets` commit SHA, `ci:full` label (collector, docs-site and GUI tests are partly invisible to + `test changed`). Link with `pull_request.link`, watch with `pull_request.watch`. +5. Push with `--no-verify` (disclosed). After the stack lands: retarget to `dev`, merge `dev` in only on conflict, + merge with `gh pr merge --squash --admin --match-head-commit ` when every rule in the brief holds. + + +## Execution notes (wp7 P, 2026-11-09) + +- Issue: #8372 (feature proposal template). +- Reviews run in parallel on DeepSeek (`command-code/deepseek-deepseek-v4.1-flash-fast` → `hyds/deepseek-v4.1-flash-free` + → `mistral/deepseek-v4-1-flash-1017` on HTTP 401, default model if all fail); the model that ran is reported. +- Full `bun run test` in isolated homes runs alongside; `codex-cli-update-apply` fails locally only because this machine + has live Codex sessions (`blocked_active_session`) and is left to hosted CI. +- PR screenshots: `.tmp/shots/profile-card-connect.png` and `profile-card-connected.png`, uploaded to the `pr-assets` + branch and linked by commit SHA. diff --git a/devlog/_plan/261109_telemetry_personal_rank/070_merge.md b/devlog/_plan/261109_telemetry_personal_rank/070_merge.md new file mode 100644 index 00000000000..9b019793241 --- /dev/null +++ b/devlog/_plan/261109_telemetry_personal_rank/070_merge.md @@ -0,0 +1,40 @@ +# 070 — Retarget, maintainer judgment and merge (wp8) + +Waits for the telemetry stack (#8300 → #8348 → #8303 → #8304 → #8305 → #8308) to land on `dev`; the coordinator +announces #8308's merge (decision `stackwait`, 2026-11-09). Do not poll. + +## Prep done before the stack lands + +- Regression guard for existing users: `tests/usage/usage-telemetry-profile-lifecycle.test.ts` proves that a proxy with + no `telemetry-profile.json` starts no profile loop and loads no profile module at background start, with telemetry on + or off (`OCX_TELEMETRY=0`), so installs that never connect a profile behave as before. + +## Steps after #8308 merges + +1. `gh pr edit 8393 --base dev`; merge `dev` into the branch only if GitHub reports a conflict. +2. `git commit --allow-empty -m "ci: build on current dev"` and `git push --no-verify` (CI then builds on current `dev`, + which carries 2.100.0 and clears the inherited `release-version-line` failure). +3. Post the Maintainer judgment comment (draft below), citing the head SHA. +4. Fresh independent review (task.create role=review) on that exact head; fold any P0/P1 or correctness/security P2 with a + failing-first test and repeat on the new head. +5. When the exact head's hosted CI is green: `gh pr ready 8393`, then + `gh pr merge 8393 --squash --admin --match-head-commit `; close #8372 manually (PRs into `dev` do not auto-close). +6. Report via origin.report (Korean) with PR URL, merge SHA, judgment summary, NOT RUN. + +## Draft: Maintainer judgment + +> **Value.** Implements #8372: an opt-in public profile and personal usage rank, built on the anonymous telemetry +> stack, as requested by the owner. Not a duplicate; no other open PR touches profiles or a leaderboard. +> +> **UX change — intentional and documented.** New surfaces only: a *Public profile & rank* card on the dashboard Home +> (shown while anonymous telemetry is on, or while a profile exists), `ocx telemetry profile connect|disconnect|status`, +> collector profile/leaderboard endpoints, and a leaderboard section on opencodex.me/dashboard. Existing flows, defaults, +> CLI output of existing commands, config semantics and the anonymous payload are unchanged; the README privacy line, +> code-signing pages, telemetry guide and `AGENTS_INSTALL.md` describe the new opt-in. +> +> **No regression for existing users.** Nothing happens until a person connects: no profile file means no profile loop +> and no profile module loaded (lifecycle test), the card stays hidden while telemetry is off (GUI test), telemetry-off +> users send nothing (sync tests for `OCX_TELEMETRY=0`, `DO_NOT_TRACK=1`, declined), connecting never writes +> `config.json` or the anonymous telemetry files, and the core request path does not reach `src/telemetry/` (boundary +> test). Reviews: , privacy review PASS-WITH-NITS (P2 fixed); exact-head CI . + diff --git a/devlog/_plan/261110_claude_code_identity_fable52/010_plan.md b/devlog/_plan/261110_claude_code_identity_fable52/010_plan.md new file mode 100644 index 00000000000..28ac87b41ad --- /dev/null +++ b/devlog/_plan/261110_claude_code_identity_fable52/010_plan.md @@ -0,0 +1,48 @@ +# 261110 Claude Code identity bump for Fable 5.2 + +## Problem + +Anthropic OAuth (Claude subscription) requests for `claude-fable-5-2` fail upstream: +`400 invalid_request_error: Claude Code 2.1.280 does not support this model; version 2.1.290 or newer is required`. +opencodex presents a fixed Claude Code identity from `src/providers/claude-cli-identity.ts` +(`CLAUDE_CLI_VERSION = "2.1.280"`, User-Agent `claude-cli/2.1.280 (external, cli)`), plus the Stainless +SDK/runtime pins consumed by `src/adapters/client-fingerprint.ts`. + +## Evidence + +- npm `@anthropic-ai/claude-code`: latest 2.1.322, stable 2.1.311 (2026-11-10). +- Installed CLI `~/.local/share/claude/versions/2.1.319`: binary strings carry `@anthropic-ai/sdk` "0.128.0", + runtime `v26.12.1` (Bun v1.4.4 embedded). + +## Diff-level plan (wp1) + +1. `src/providers/claude-cli-identity.ts`: CLAUDE_CLI_VERSION 2.1.321, SDK 0.128.0, runtime v26.12.1 + (one coherent fingerprint taken from one real binary); note the Fable 5.2 floor in the doc comment. +2. `src/adapters/client-fingerprint.ts`: header comment names 2.1.321 / sdk 0.128.0. +3. `tests/clients/client-fingerprint.test.ts`: update the two pinned values; add a regression assertion that + the presented CLI version is >= 2.1.290 (Anthropic's Fable 5.2 minimum). +4. Live check: temporary proxy on the real config home, POST /v1/messages claude-fable-5-2, expect a model reply. + +## Verification + +`bun test tests/clients/client-fingerprint.test.ts` and Anthropic adapter tests, `bun run typecheck`, +`bun run test:changed`, live call. Then PR, merge, release (lane=all CI + service-lifecycle + dry run + publish). + +## Audit fold (terra NEAR-PASS) + +1. Scope: the reported 400 names 2.1.280, which is exactly the proxy-generated identity (the local caller is + 2.1.319), so this fix targets the proxy identity. Native passthrough that forwards a caller's own older + identity still needs that caller to update Claude Code; the live check exercises the proxy identity. +2. Only the three version strings move; the remaining Stainless fields, beta list and anthropic-version are not + tied to the CLI version in this repo. The live call proves route compatibility, not a full fingerprint match. +3. Test pins: only tests/clients/client-fingerprint.test.ts SDK/runtime asserts move; caller-identity fixtures stay. + The SDK pin also changes the API-key User-Agent (@anthropic-ai/sdk/0.128.0), which matches the real SDK. + + +## Reference + +router-for-me/CLIProxyAPI#7392 (merged 2026-11-10, fixes their #7386) hit the same 400 and bumped its emulated +baseline to 2.1.321 / sdk 0.128.0 / runtime v26.12.1 from a mitmproxy capture. This unit aligns to that measured +baseline. Their beta-header reordering and billing cc_version changes do not apply: opencodex assembles no +version-specific billing header and only passes caller betas through its allowlist. + diff --git a/devlog/_plan/261110_fable_5_2_catalog/010_plan.md b/devlog/_plan/261110_fable_5_2_catalog/010_plan.md new file mode 100644 index 00000000000..200d05e0cca --- /dev/null +++ b/devlog/_plan/261110_fable_5_2_catalog/010_plan.md @@ -0,0 +1,145 @@ +# 261110 Claude Fable 5.2 catalog, pricing and fast mode + +## Problem + +Anthropic released Claude Fable 5.2 (`claude-fable-5-2`) on 2026-11-09. Live Anthropic +discovery will list it, but no static row knows it: the picker would show no price, no max +output, no effort ladder, and every logged request would be unpriced. Fable 5.2 is also the +first Fable model with Claude API fast mode, which the Anthropic fast wire currently limits to +Opus models. + +## Evidence (collected 2026-11-10) + +| Source | Surface | Facts | +|---|---|---| +| platform.claude.com/docs/en/models/fable-5-2/overview | Aside repl | id `claude-fable-5-2`; 1M context; 128K output; adaptive thinking always on; default effort high; text+image in; $8 in / $40 out / $10 5m write / $16 1h write / $0.20 cache read; Bedrock `anthropic.claude-fable-5-2`; forced tool_choice 400; omit temperature/top_p/top_k | +| platform.claude.com/docs/en/models/fable-5-2/whats-new-fable-5-2 | Aside repl | breaking changes vs 5.1: prefix check on every account, 5.2 thinking blocks readable only by 5.2/Mythos 5.2, computer_20251124 removed (toolset only), advisor must be 5.2. thinking enabled/disabled 400; forced tool_choice 400 (same as 5.1) | +| platform.claude.com/docs/en/about-claude/pricing | Aside repl | Fable 5.2 row 8 / 40 / 10 / 16 / 0.20 (0.025x footnote); fast mode Fable 5.2 $16 / $80 on the Claude API (2x) | +| platform.claude.com/docs/en/about-claude/models/overview | Aside repl | Fable 5.2 row, default effort high, 1M | +| cursor.com/docs/models/claude-fable-5-2 | Aside exec | id `claude-fable-5-2`, 300K default / 1M max, low..max, always thinks with no toggle, 8 / 40 / 10 / 0.20 | +| docs.github.com copilot models-and-pricing; github.blog changelog 2026-11-09 | Aside exec | Claude Fable 5.2 GA, 8 / 40 / 0.20 cached / 10 write; Pro+, Max, Business, Enterprise | +| opper.ai/models/claude-fable-5-2 | Aside exec | `anthropic/claude-fable-5-2` route (Anthropic US), 1M / 128K, 8 / 40 / 0.20 / 10 | +| docs.devin.ai/desktop/models | Aside exec | Claude Fable 5.2 listed, low..max plus fast variants, 8 / 40 / 0.20 / 10; raw id not published | +| kiro.dev/docs/models | Aside exec | NOT FOUND (Fable 5.1 only) | +| openrouter.ai/api/v1/models | curl | `anthropic/claude-fable-5.2` 1M / 128K 8 / 40 / 0.2 / 10; `~anthropic/claude-fable-latest` now 8 / 40 / 0.2 / 10 | +| ai-gateway.vercel.sh/v1/models | curl | `anthropic/claude-fable-5.2` 8 / 40 / 0.2 / 10 and `-fast` 16 / 80 / 0.4 / 20 | +| api.kilo.ai openrouter models | curl | `anthropic/claude-fable-5.2` and `~anthropic/claude-fable-latest` 8 / 40 / 0.2 / 10 | +| api.venice.ai/api/v1/models | curl | `claude-fable-5-2` 8 / 40 / 0.2 / 10 | +| models.dev/api.json | curl | anthropic, amazon-bedrock (`anthropic.`, `global.` at list; `us.` at 1.1x), opencode, kilo, venice, vercel, openrouter rows, all 1M / 128K | + +## Diff-level plan (wp1, one PABCD cycle) + +1. `src/providers/registry/model-seeds.ts`: `claude-fable-5-2` first in `ANTHROPIC_MODELS`, + 1M in `ANTHROPIC_MODEL_CONTEXT_WINDOWS`, 128K in `ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS`, + provenance comment; Opper pool seed `claude-fable-5-2` (1M / 128K) beside `claude-opus-5-5`. +2. `src/providers/registry/entries-core.ts`: `ANTHROPIC_FAST_MODELS["claude-fable-5-2"] = true` + with the comment updated (Fable 5.2 accepts `speed`; Fable 5 and 5.1 still reject it); Devin + seed list gains `claude-fable-5-2` next to `claude-fable-5-1`. +3. `src/usage/expected-prices.ts`: `CLAUDE_FABLE_52 = { 8, 40, 0.2, 10 }`; overlays anthropic, + anthropic-apikey (verified), cursor (verified, Cursor model page), devin and devin-cli + (verified-derived, Devin model page lists the price, uid unpublished); Anthropic fast + priority rule (2x, response confirmation) for anthropic and anthropic-apikey. +4. Cursor: price overlay only. No preemptive capability or effort-map row: Cursor documents no + wire ids, and a registered base would parse a future `-thinking` roster id as a known variant + and compose the bare base when that variant is absent (catalog.ts composeWireId). The capability + row lands once the live GetUsableModels roster shows the real ids. `normalizeCursorClaudeId` + already canonicalizes every 5.2 spelling onto `claude-fable-5-2` without a capability row. +5. `src/adapters/devin/live-models.ts`: seed id and 1M window (preemptive; Devin publishes the + model but not its uid, so live discovery owns the real id). +6. `scripts/model-metadata.source.json` then `bun run generate:model-metadata`: rows cloned from + each provider's `claude-fable-5` / `claude-fable-5-1` shape with 5.2 values: anthropic; + amazon-bedrock `anthropic.`, `global.` (list) and `us.` (1.1x); github-copilot; openrouter, + kilo `anthropic/claude-fable-5.2`; vercel-ai-gateway `anthropic/claude-fable-5.2` and `-fast`; + venice and opencode-zen `claude-fable-5-2`; reprice the openrouter and kilo + `~anthropic/claude-fable-latest` alias rows to 8 / 40 / 0.2 / 10 (both APIs list the alias at that + price; the kilo row was all-zero, which the resolver treats as unpriced). +7. Tests: `tests/usage/usage-cost.test.ts` (overlays, snapshot rows, both alias reprices, cursor + spellings, overlay roster count); `tests/usage/usage-anthropic-fast-pricing.test.ts` (Fable 5.2 in + the shared price table, so confirmed / assumed / downgraded costs are all pinned); + `tests/adapters/anthropic/anthropic-fast-speed.test.ts` (registry map equality, Fable 5.2 + eligible and Fable 5.1 unclassified, Fable 5.2 request carries `speed: fast` and the beta); + devin seed test; opper seed; pinned rosters the change trips. +8. Docs: English `reference/configuration/providers.md` Anthropic Fast list and + `guides/claude-code.md` native Fast list gain `claude-fable-5-2`. + +Out of scope, recorded as residuals: Claude Code native `fable` tier default (stays 5.1; moves +with its GUI fill and docs in a separate change, as Opus 5.5's did in #5826), Claude Mythos 5.2, +Kiro (not published), Cursor fast ids (not published), cloudflare/zenmux rows (unverified). + +## Verification + +- `bun run generate:model-metadata`; `bun test tests/codex-integration/model-metadata-sync.test.ts` +- `bun test tests/usage tests/adapters/anthropic tests/providers/cursor tests/providers/devin-adapter.test.ts tests/providers/provider-registry-parity.test.ts tests/codex-integration/fast-row.test.ts` + plus `bun run test:changed` +- `bun run typecheck`, file-size ratchet and test-layout tests, `bun run structure:check`, + `bun run privacy:scan` + +## Bounds + +Write scope: files above plus this devlog unit. Push, PR and merge to `dev` are authorized by the +user for this goal; no release or deploy. Wall clock: none stated; host limits apply. + +## Research reconciliation (terra lane, 2026-11-10) + +The gpt-6.1-terra research lane confirmed the official facts and the touchpoint list. Its open +gaps resolve as follows: + +- Kilo and Venice: verified directly from their public model APIs (table above). +- Copilot: price verified on docs.github.com copilot models-and-pricing (Aside exec); the snapshot + id follows Copilot's existing dash spelling (`claude-fable-5`, `claude-opus-5-5`), so the row + is `claude-fable-5-2`, copied from its `claude-fable-5` row shape. +- Devin: published UIDs include `claude-fable-5-2-high` and `claude-fable-5-2-high-fast`; the + seed carries the base id the same way `claude-fable-5-1` does, and live discovery owns the rest. +- Native Claude Code `fable` alias: out of scope (residual above). +- Preserved thinking across model switches (Fable 5.1 cannot read 5.2 blocks; prefix check on every + account): the API drops unreadable blocks and succeeds, so a combo fallback away from 5.2 loses + reasoning without failing. No adapter change in this unit; recorded as a residual. + +Retained as-is per the lane: `stale-no-vision-seed-migration.ts` (exact historical roster +equality), `stale-context-window-migration.ts`, `command-code-efforts.ts` (captured profile), +Kiro (unpublished). + + +## Audit round 1 fold (terra, FAIL -> revised) + +1. BLOCKER fast roster test: `tests/adapters/anthropic/anthropic-fast-speed.test.ts` (registry + `modelSupportsServiceTier` equality) joins the write scope; expected map gains + `claude-fable-5-2` and the test asserts Fable 5.2 eligibility on both entries while Fable 5.1 + stays unclassified. +2. BLOCKER docs: `docs-site/src/content/docs/reference/configuration/providers.md` (Anthropic Fast + section) and `docs-site/src/content/docs/guides/claude-code.md` (native Fast list) add + `claude-fable-5-2`. No translated page enumerates the Anthropic Fast models (checked with rg), + so locales need no change. +3. Opper pool verified: `api.opper.ai/v3/models?q=claude-fable-5-2` returns + `anthropic/claude-fable-5-2` with `pooled: true`, model `claude-fable-5-2`, 1M / 128K. +4. Cursor: flat regular low..max stays provisional; tests cover the canonical id, its effort + suffix spellings and that 5.2 never resolves to the 5.1 row. Thinking-suffixed ids from a + future roster fall back to normal discovery handling. +5. Price coverage: tests pin anthropic / anthropic-apikey / cursor / devin / devin-cli overlays, + the openrouter `~anthropic/claude-fable-latest` reprice, the new snapshot rows + (bedrock us. 1.1x, vercel -fast), and confirmed vs unconfirmed Fable 5.2 fast cost in + `tests/usage/usage-anthropic-fast-pricing.test.ts`. The kilo alias row is zero-cost in the + snapshot already and stays untouched. +6. Write scope additionally includes `src/generated/model-metadata.ts` (generated), the tests + above, and any test-layout registration if a sibling test file is created. + + +## Audit round 2 fold (terra, FAIL -> revised) + +1. BLOCKER kilo alias: repriced to 8 / 40 / 0.2 / 10 (plan item 6) and pinned in usage-cost. +2. Cursor: preemptive capability dropped (plan item 4); price overlay only, pending live roster ids. +3. Fast checks: request-shape, eligibility, and confirmed / assumed / downgraded cost cases named in + plan item 7; commands in Verification. +4. Earlier sections rewritten so item 4, 6, 7, 8 and Verification state the final decisions; the + round-1 fold items 4 and 5 are superseded where they differ. + +## Audit round 3 (terra, NEAR-PASS) + +No blocker. Residual SHOULDs folded: + +1. Cursor flat effort suffixes (`claude-fable-5-2-high`) do not canonicalize without an effort-map + row, so they stay unpriced until the live roster confirms Cursor's ids; usage-cost pins the + bare and `-thinking-high` spellings that do resolve, and the flat suffix as the known gap. +2. `tests/codex-integration/fast-row.test.ts` gains a Fable 5.2 case: with Anthropic Fast opted in, + the anthropic entry publishes a fast row for `claude-fable-5-2` and none for `claude-fable-5-1`. + diff --git a/devlog/_plan/261110_fable_5_2_catalog/030_done.md b/devlog/_plan/261110_fable_5_2_catalog/030_done.md new file mode 100644 index 00000000000..29f32036eaf --- /dev/null +++ b/devlog/_plan/261110_fable_5_2_catalog/030_done.md @@ -0,0 +1,27 @@ +# 261110 Fable 5.2 catalog — outcome + +Shipped in two commits on `goat/fable-5-2-release`: + +- `bbb07cda78` adds the Anthropic seed (1M / 128K / low..max), the fast allowlist and 2x + confirmed-fast price rule, overlays for anthropic, anthropic-apikey, cursor, devin and devin-cli, + the metadata snapshot rows plus regenerated table, Opper and Devin seeds, and the two English docs + that list Anthropic Fast models. +- `0b42b837f8` prices the two ids the vendor fallback cannot reach (Kilo's fable-latest alias and + Vercel's Fable 5.2 fast row), moves the Fable 5.2 pricing cases into + `tests/usage/usage-fable-5-2-pricing.test.ts` to keep `usage-cost.test.ts` under the 2000-line + ratchet, and adds Fable 5.2 to the client-export fast roster test. + +Review: terra plan audit FAIL, FAIL, NEAR-PASS; terra code review FAIL (ratchet, two unpriced ids), +then PASS. + +Evidence: `bun run typecheck`, `privacy:scan` and `structure:check` pass; 12 focused files 356 pass; +`bun run test:changed` 52325 pass with one unrelated failure +(`tests/clients/link-pinned-sync-argv.test.ts` signal timing under load, passes in isolation). + +What did not improve: Cursor flat effort ids (`claude-fable-5-2-high`) stay unpriced until the live +roster shows Cursor's real ids; the Claude Code native `fable` tier still defaults to 5.1. The +assumption that would refute the Cursor choice: a roster that lists only flat effort ids, in which +case a capability row and effort-map entry are the next step. + +Residuals: native `fable` tier default, Claude Mythos 5.2, Kiro (unpublished), model switches away +from 5.2 dropping its thinking blocks (the API succeeds and drops them). diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 7c9c1558a3e..2423554b630 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -27,15 +27,17 @@ export default defineConfig({ "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI, App, SDK, and Claude Code.", tagline: "Use any LLM with OpenAI Codex and Claude Code.", logo: { - light: "./src/assets/logo-light.png", - dark: "./src/assets/logo-dark.png", + light: "./src/assets/logo-light.svg", + dark: "./src/assets/logo-dark.svg", replacesTitle: false, }, favicon: "/favicon.ico", customCss: [ "@fontsource-variable/geist", - "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css", + "@fontsource-variable/geist-mono", + "wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.css", "./src/styles/custom.css", + "./src/styles/site-theme.css", ], components: { Header: "./src/components/Header.astro", @@ -44,6 +46,10 @@ export default defineConfig({ head: [ // Google favicon guidelines: PNG at a multiple of 48px, exposed via rel="icon". { tag: "link", attrs: { rel: "icon", type: "image/png", sizes: "192x192", href: "/favicon.png" } }, + // The vector mark recolours itself for dark browser chrome; browsers that + // understand SVG icons prefer it over the generated ICO/PNG (scripts/brand-favicons.ts). + { tag: "link", attrs: { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" } }, + { tag: "link", attrs: { rel: "apple-touch-icon", href: "/favicon.png" } }, { tag: "meta", attrs: { property: "og:image", content: `${SITE_URL}/og.png` } }, { tag: "meta", attrs: { property: "og:image:width", content: "1200" } }, { tag: "meta", attrs: { property: "og:image:height", content: "630" } }, @@ -118,6 +124,7 @@ export default defineConfig({ { label: "Image Bridge", translations: { fr: "Pont d’images", ko: "이미지 브릿지", "zh-CN": "图像桥接", "zh-TW": "圖像橋接", ru: "Image Bridge", ja: "画像ブリッジ", tr: "Image Bridge" }, slug: "guides/image-bridge" }, { label: "Video Bridge", translations: { fr: "Pont vidéo", ko: "비디오 브릿지", "zh-CN": "视频桥接", "zh-TW": "影片橋接", ru: "Video Bridge", ja: "動画ブリッジ", tr: "Video Bridge" }, slug: "guides/video-bridge" }, { label: "Web Dashboard", translations: { fr: "Tableau de bord web", ko: "웹 대시보드", "zh-CN": "网页控制台", "zh-TW": "網頁儀表板", ru: "Веб-дашборд", ja: "ウェブダッシュボード", tr: "Web Kontrol Paneli" }, slug: "guides/web-dashboard" }, + { label: "Telemetry & Privacy", translations: { fr: "Télémétrie et confidentialité", ko: "텔레메트리 및 개인정보", "zh-CN": "遥测与隐私", "zh-TW": "遙測與隱私", ru: "Телеметрия и конфиденциальность", ja: "テレメトリとプライバシー", tr: "Telemetri ve Gizlilik" }, slug: "guides/telemetry" }, { label: "Sub-agent Surface", translations: { fr: "Interface des sous-agents", ko: "서브에이전트 서피스", "zh-CN": "子代理界面", "zh-TW": "子代理介面", ru: "Интерфейс подагентов", ja: "サブエージェントサーフェス", tr: "Alt Ajan Arayüzü" }, slug: "guides/sub-agent-surface" }, { label: "Why v1 Is the Default", translations: { fr: "Pourquoi v1 est la valeur par défaut", ko: "v1이 기본값인 이유", "zh-CN": "为什么默认是 v1", "zh-TW": "為什麼預設是 v1", ru: "Почему v1 по умолчанию", ja: "v1 がデフォルトである理由", tr: "Neden varsayılan v1" }, slug: "guides/subagent-v1-default" }, ], diff --git a/docs-site/bun.lock b/docs-site/bun.lock index 092f7d2349d..954219debae 100644 --- a/docs-site/bun.lock +++ b/docs-site/bun.lock @@ -7,11 +7,13 @@ "dependencies": { "@astrojs/starlight": "^0.41.7", "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "5.3.0", "astro": "^7.3.3", "echarts": "^6.1.0", "pretendard": "^1.3.9", "sharp": "^0.35.4", "simple-icons": "^16.28.0", + "wanted-sans": "1.0.3", }, }, }, @@ -165,6 +167,8 @@ "@fontsource-variable/geist": ["@fontsource-variable/geist@5.3.0", "", {}, "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA=="], + "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.3.0", "", {}, "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], @@ -927,6 +931,8 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "wanted-sans": ["wanted-sans@1.0.3", "", {}, "sha512-YU+mQRJMc97zMGFmxFjOvjdrF5Hi2zagc69bNP9n//i77Dlf9AWGfziZWYKdhQzPK5tw+e0iHcjLrZXDatxRTQ=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], diff --git a/docs-site/package.json b/docs-site/package.json index 2c1f923c83d..59cdbdd766e 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -13,11 +13,13 @@ "dependencies": { "@astrojs/starlight": "^0.41.7", "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "5.3.0", "astro": "^7.3.3", "echarts": "^6.1.0", "pretendard": "^1.3.9", "sharp": "^0.35.4", - "simple-icons": "^16.28.0" + "simple-icons": "^16.28.0", + "wanted-sans": "1.0.3" }, "overrides": { "svgo": "4.1.0", diff --git a/docs-site/public/favicon.svg b/docs-site/public/favicon.svg new file mode 100644 index 00000000000..bd0cf20828f --- /dev/null +++ b/docs-site/public/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs-site/public/grain.svg b/docs-site/public/grain.svg new file mode 100644 index 00000000000..9b7a1133c22 --- /dev/null +++ b/docs-site/public/grain.svg @@ -0,0 +1 @@ + diff --git a/docs-site/src/assets/logo-dark.svg b/docs-site/src/assets/logo-dark.svg new file mode 100644 index 00000000000..5b5acb93733 --- /dev/null +++ b/docs-site/src/assets/logo-dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs-site/src/assets/logo-light.svg b/docs-site/src/assets/logo-light.svg new file mode 100644 index 00000000000..0797892fe07 --- /dev/null +++ b/docs-site/src/assets/logo-light.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs-site/src/components/CloudCanvas.astro b/docs-site/src/components/CloudCanvas.astro new file mode 100644 index 00000000000..6abc9d88474 --- /dev/null +++ b/docs-site/src/components/CloudCanvas.astro @@ -0,0 +1,316 @@ +--- +// Animated WebGL2 cloud sky for the hero and closing panel. Renders into an +// absolutely positioned host that fills its nearest positioned ancestor. +// Without WebGL2 the host keeps its CSS sky colour, so the section still reads. +// Under reduced motion one still frame is drawn and re-drawn on theme change. +interface Props { + class?: string; + /** `sky` paints a full daylight sky; `overlay` draws only the clouds, + * dusk-lit and translucent, over whatever sits behind the canvas. */ + variant?: 'sky' | 'overlay'; +} +const { class: className, variant = 'sky' } = Astro.props; +--- + + + + diff --git a/docs-site/src/components/Header.astro b/docs-site/src/components/Header.astro index 1e6c9e2d582..7e86a7de5dc 100644 --- a/docs-site/src/components/Header.astro +++ b/docs-site/src/components/Header.astro @@ -17,7 +17,34 @@ const base = import.meta.env.BASE_URL.replace(/\/?$/, '/'); const prefix = locale ? `${base}${locale}/` : base; type NavLink = { label: string; href: string }; -type NavGroup = { label: string; links: NavLink[] }; +type NavGroup = { label: string; links: NavLink[]; more?: NavLink }; + +// Dropdown rows: every entry gets an icon tile. +// Lucide outline paths (24x24, stroke), keyed by the link's slug. +const ICONS: Record = { + 'getting-started/installation/': 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3', + 'getting-started/quickstart/': 'M13 2 3 14h9l-1 8 10-12h-9l1-8Z', + 'getting-started/how-it-works/': 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a9 9 0 0 1-9 9', + 'getting-started/for-agents/': 'M12 8V4H8m8 0h-4M4 12a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Zm11 3h.01M9 15h.01', + 'guides/providers/': 'M12 22v-5M9 8V2M15 8V2M6 8h12l-1 6a5 5 0 0 1-10 0Z', + 'guides/model-routing/': 'M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM9 16h5.5a3.5 3.5 0 0 0 0-7H15', + 'guides/combos/': 'm12 2 10 5-10 5L2 7Zm-10 10 10 5 10-5M2 17l10 5 10-5', + 'guides/codex-integration/': 'M16 18l6-6-6-6M8 6l-6 6 6 6', + 'guides/codex-app-models/': 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm6-2v18M3 9h6', + 'guides/claude-code/': 'M4 17l6-6-6-6M12 19h8', + 'guides/sidecars/': 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm10 2-4.35-4.35', + 'guides/image-bridge/': 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm6 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm12 4-3.09-3.09a2 2 0 0 0-2.82 0L6 21', + 'guides/video-bridge/': 'm16 13 5.22 3.48a.5.5 0 0 0 .78-.42V7.94a.5.5 0 0 0-.76-.43L16 10.5M4 6h10a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2Z', + 'guides/web-dashboard/': 'M3 3v16a2 2 0 0 0 2 2h16M7 16V9m5 7V5m5 11v-4', + 'guides/sub-agent-surface/': 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75', + 'reference/cli/': 'M4 17l6-6-6-6M12 19h8', + 'reference/configuration/': 'M20 7h-9M14 17H5M17 20a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM7 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z', + 'reference/adapters/': 'M12 22v-5M9 8V2M15 8V2M6 8h12l-1 6a5 5 0 0 1-10 0Z', + 'reference/architecture/': 'M3 3h7v7H3Zm11 0h7v7h-7Zm0 11h7v7h-7ZM3 14h7v7H3Z', + 'reference/proxy-formats/': 'M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1m8 0h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1', + 'reference/management-api/': 'M2 4h20v6H2Zm0 10h20v6H2ZM6 7h.01M6 17h.01', +}; +const iconFor = (href: string) => ICONS[href.slice(prefix.length)] ?? ICONS['guides/codex-integration/']; const fr = { 'Auto': 'Automatique', @@ -25,7 +52,7 @@ const fr = { 'Dark': 'Sombre', 'Theme': 'Thème', 'Click to use the next theme': 'Cliquez pour passer au thème suivant', - 'Getting Started': 'Démarrage', + 'Getting Started': 'Premiers pas', 'Installation': 'Installation', 'Quickstart': 'Démarrage rapide', 'How It Works': 'Fonctionnement', @@ -47,7 +74,9 @@ const fr = { 'Architecture': 'Architecture', 'Proxy API Formats': 'Formats de l’API proxy', 'Management API': 'API de gestion', + 'Dashboard': 'Tableau de bord', 'Contributing': 'Contribuer', + 'Explore guides': 'Explorer les guides', 'Main': 'Navigation principale', 'Select language': 'Choisir la langue', 'Menu': 'Menu', @@ -62,8 +91,8 @@ const starCount = await getStarCount(); const starLocale = locale ?? 'en'; const githubLabel = t('GitHub', 'GitHub', 'GitHub', 'GitHub', 'GitHub', 'GitHub', 'GitHub'); const starLabel = t( - 'GitHub, {count} stars', 'GitHub, 별 {count}개', 'GitHub,{count} 个星标', - 'GitHub, {count} звёзд', 'GitHub、{count} スター', 'GitHub,{count} 個星標', 'GitHub, {count} yıldız', + 'GitHub, {count} stars', 'GitHub, 스타 {count}개', 'GitHub,{count} 个星标', + 'GitHub, звёзд: {count}', 'GitHub、{count} スター', 'GitHub,{count} 個星標', 'GitHub, {count} yıldız', ); const githubAccessibleName = starCount === null ? githubLabel : starLabel.replace('{count}', starCount.toLocaleString(starLocale)); @@ -113,7 +142,7 @@ const groups: NavGroup[] = [ { label: t('Installation', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum'), href: `${prefix}getting-started/installation/` }, { label: t('Quickstart', '빠른 시작', '快速开始', 'Быстрый старт', 'クイックスタート', '快速入門', 'Hızlı Başlangıç'), href: `${prefix}getting-started/quickstart/` }, { label: t('How It Works', '동작 원리', '工作原理', 'Как это работает', '仕組み', '運作原理', 'Nasıl Çalışır'), href: `${prefix}getting-started/how-it-works/` }, - { label: t('Agent Quickstart', '에이전트 퀵스타트', 'Agent 快速上手', 'Быстрый старт для агентов', 'エージェント向けクイックスタート', 'Agent 快速上手', 'Ajanlar İçin Hızlı Başlangıç'), href: `${prefix}getting-started/for-agents/` }, + { label: t('Agent Quickstart', '에이전트용 빠른 시작', 'Agent 快速上手', 'Быстрый старт для агентов', 'エージェント向けクイックスタート', 'Agent 快速上手', 'Ajanlar İçin Hızlı Başlangıç'), href: `${prefix}getting-started/for-agents/` }, ], }, { @@ -145,17 +174,36 @@ const groups: NavGroup[] = [ }, ]; +const dashboard: NavLink = { + label: t('Dashboard', '대시보드', '仪表盘', 'Дашборд', 'ダッシュボード', '儀表板', 'Panel'), + href: `${prefix}dashboard/`, +}; + +// Eleven guides made the Guides panel read like a sitemap. The panel keeps +// the six people reach for first and ends in a row that jumps to the curated +// docs map on the landing page. That band is a selection of guide pages, not +// an index of them, and the docs sidebar is a subset too, so the row invites +// exploration instead of claiming it lists every guide. +const GUIDE_PICKS = ['providers', 'model-routing', 'codex-integration', 'claude-code', 'sidecars', 'web-dashboard']; +const guides = groups[1]; +guides.links = guides.links.filter((link) => GUIDE_PICKS.some((slug) => link.href.endsWith(`guides/${slug}/`))); +guides.more = { + label: t('Explore guides', '가이드 둘러보기', '浏览指南', 'Обзор руководств', 'ガイドを探る', '瀏覽指南', 'Kılavuzlara göz atın'), + href: `${prefix}#docs-map`, +}; + const contributing: NavLink = { - label: t('Contributing', '기여하기', '贡献', 'Участие в проекте', 'コントリビュート', '貢獻', 'Katkıda Bulunma'), + label: t('Contributing', '기여하기', '贡献指南', 'Участие в проекте', 'コントリビュート', '貢獻指南', 'Katkıda Bulunma'), href: `${prefix}contributing/`, }; --- -
+
-
+
+
+ {t("proxyV2.telemetrySentTitle")}: + {t("proxyV2.telemetrySentItems")} +
+
+ {t("proxyV2.telemetryNeverTitle")}: + {t("proxyV2.telemetryNeverItems")} +
+
+

+ {t("proxyV2.telemetryPreviewHint")} +

+ {status.previewEligible === false && ( +

+ {t("proxyV2.telemetryPreviewIneligible")} +

+ )} +
{formatTelemetryPreviewJson(status)}
+
+ +
+
+
+ )} + + ); +} + +export default TelemetrySettingsPanel; diff --git a/gui/src/components/add-provider-form-pane.tsx b/gui/src/components/add-provider-form-pane.tsx index e0e547a7bc1..4da3375fa26 100644 --- a/gui/src/components/add-provider-form-pane.tsx +++ b/gui/src/components/add-provider-form-pane.tsx @@ -183,6 +183,16 @@ export function AddProviderFormPane({ )} )} + {preset.id === "google-vertex" && form.adapter === "google" && <> +

{t("modal.vertexAdcHint")}

+

{t("modal.vertexStaticHint")}

+ + onFormChange({ ...form, project: e.target.value })} /> + + + onFormChange({ ...form, location: e.target.value })} placeholder="us-central1" /> + + } {!isReservedForward && onFormChange({ ...form, defaultModel: e.target.value })} placeholder={t("modal.defaultModelPlaceholder")} /> } diff --git a/gui/src/components/desktop-star-onboarding.tsx b/gui/src/components/desktop-star-onboarding.tsx index 65b5e2d8839..9e393a98566 100644 --- a/gui/src/components/desktop-star-onboarding.tsx +++ b/gui/src/components/desktop-star-onboarding.tsx @@ -48,9 +48,26 @@ function openInBrowser(url: string) { window.location.assign(url); } -export function DesktopStarOnboarding({ apiBase, enabled }: { apiBase: string; enabled: boolean }) { +export function DesktopStarOnboarding({ + apiBase, + enabled, + onSettled, +}: { + apiBase: string; + enabled: boolean; + onSettled?: () => void; +}) { const [status, setStatus] = useState(null); const [done, setDone] = useState(onboardingSeen); + const settledRef = useRef(false); + + useEffect(() => { + if (!enabled || settledRef.current) return; + if (done || !isDesktopShell()) { + settledRef.current = true; + onSettled?.(); + } + }, [done, enabled, onSettled]); useEffect(() => { if (!enabled || done || !isDesktopShell()) return; diff --git a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx index cf89714bf8c..98981a68ee1 100644 --- a/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx +++ b/gui/src/components/provider-workspace/AnthropicAccountPoolSettings.tsx @@ -8,6 +8,8 @@ import type { AnthropicInstanceId } from "../../../../src/providers/anthropic-in import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import { getPoolSettings, putPoolSettings, PoolSettingsSaveStateUnknownError } from "../../pool-settings"; +import { isApiTargetCaptureCurrent, targetFetch } from "../../api"; +import { useRenderTargetCapture } from "../../use-render-target-capture"; import "../../styles/anthropic-native-messages.css"; import { ACCOUNT_POOL_QUOTA_WINDOWS, @@ -70,16 +72,20 @@ function enabledStatus( export default function AnthropicAccountPoolSettings({ apiBase, + targetKey, provider = "anthropic", accountCount, onThresholdChange, }: { apiBase: string; + targetKey?: string; provider?: AnthropicInstanceId; accountCount: number; onThresholdChange?: (threshold: number) => void; }) { const t = useT(); + // The card's render owns the target for its save. + const renderCapture = useRenderTargetCapture(apiBase, targetKey); const [state, setState] = useState(null); const [draft, setDraft] = useState("80"); const [stickyDraft, setStickyDraft] = useState(String(DEFAULT_ACCOUNT_POOL_STICKY_LIMIT)); @@ -171,6 +177,7 @@ export default function AnthropicAccountPoolSettings({ }) => { const requestApiBase = apiBase; const requestProvider = provider; + const capture = renderCapture; saveAbortRef.current?.abort(); const controller = new AbortController(); saveAbortRef.current = controller; @@ -190,8 +197,8 @@ export default function AnthropicAccountPoolSettings({ stickyLimit: next.stickyLimit, quotaWindow: next.quotaWindow, nativeMessages: next.nativeMessages, - }, (input, init) => fetch(input, init), { signal: controller.signal }); - if (!currentRequest()) return; + }, (input, init) => targetFetch(capture, input, init), { signal: controller.signal }); + if (!currentRequest() || !isApiTargetCaptureCurrent(capture)) return; if (!json) throw new Error("save"); const savedThreshold = typeof json.autoSwitchThreshold === "number" ? json.autoSwitchThreshold : next.threshold; const savedStrategy = normalizeAccountPoolStrategy(json?.strategy ?? next.strategy); @@ -229,7 +236,7 @@ export default function AnthropicAccountPoolSettings({ if (ownsSave) saveAbortRef.current = null; if (ownsSave && mountedRef.current && apiBaseRef.current === requestApiBase && providerRef.current === requestProvider) setSaving(false); } - }, [apiBase, provider, state, t]); + }, [apiBase, provider, renderCapture, state, t]); const enabled = state?.enabled === true; const threshold = state?.threshold ?? 80; diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 41f8e541952..e6b84d7216d 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -32,6 +32,8 @@ import { useGrokResetCoupons } from "../../hooks/useGrokResetCoupons"; import { useAnthropicResetGrants } from "../../hooks/useAnthropicResetGrants"; import { Switch } from "../../ui"; import { kiroSkipReasonKey } from "../../kiro-device-login-helpers"; +import { isApiTargetCaptureCurrent, targetFetch, type ApiTargetCapture } from "../../api"; +import { useRenderTargetCapture } from "../../use-render-target-capture"; import KiroDeviceLoginDialog from "../KiroDeviceLoginDialog"; import type { AccountLoadState, @@ -39,8 +41,7 @@ import type { ApiKeyRow, LoginHint, ProviderAuthHandlers, - ProviderUpdatePatch, - ProviderUpdateResult, + ProviderUpdateCallback, } from "./types"; const COCKPIT_IMPORT_MAX_BYTES = 256 * 1024; @@ -61,7 +62,7 @@ function XaiChatOptInControl({ onUpdateProvider, }: { initialState: NonNullable; - onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise; + onUpdateProvider?: ProviderUpdateCallback; }) { const t = useT(); const [state, setState] = useState(initialState); @@ -182,12 +183,14 @@ function safeCockpitImportResult(value: unknown): CockpitImportResult | null { } export default function ProviderAuthPanel({ - item, apiBase, oauth, accounts = EMPTY_OAUTH_ACCOUNTS, keys = EMPTY_API_KEYS, accountLoadState = "ready", - switchingAccountId = null, pausingAccountId = null, busy = false, loginHint, authHandlers, onCodexActiveNeedsReauthChange, + item, apiBase, targetKey, oauth, accounts = EMPTY_OAUTH_ACCOUNTS, keys = EMPTY_API_KEYS, accountLoadState = "ready", + switchingAccountId = null, pausingAccountId = null, busy = false, loginHint, loginCapture, authHandlers, onCodexActiveNeedsReauthChange, codexController, onUpdateProvider, }: { item: WorkspaceItem; apiBase: string; + /** Logical dashboard target; the panel's rows and actions belong to it. */ + targetKey?: string; oauth?: { loggedIn: boolean; email?: string; error?: string }; accounts?: OAuthAccountRow[]; keys?: ApiKeyRow[]; @@ -196,13 +199,16 @@ export default function ProviderAuthPanel({ pausingAccountId?: string | null; busy?: boolean; loginHint?: LoginHint | null; + loginCapture?: ApiTargetCapture; authHandlers?: ProviderAuthHandlers; onCodexActiveNeedsReauthChange?: (needs: boolean) => void; - onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise; + onUpdateProvider?: ProviderUpdateCallback; /** Shared Codex account state owned by Providers (WP3). */ codexController?: CodexAccountPoolController; }) { const t = useT(); + // The panel's render owns the target for its own import action. + const renderCapture = useRenderTargetCapture(apiBase, targetKey); const { locale } = useI18n(); const [addingKey, setAddingKey] = useState(false); const [kiroChooser, setKiroChooser] = useState<{ addAccount: boolean } | null>(null); @@ -289,6 +295,7 @@ export default function ProviderAuthPanel({
{ const input = manualCode.trim(); if (!input || manualCodeBusy) return; + if (!loginCapture) { + setManualCodeOk(false); + setManualCodeMsg(t("prov.targetChanged")); + return; + } setManualCodeBusy(true); setManualCodeMsg(""); try { - const res = await fetch(`${apiBase}/api/oauth/login/code`, { + const res = await targetFetch(loginCapture, `${apiBase}/api/oauth/login/code`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: item.name, input }), }); + if (!isApiTargetCaptureCurrent(loginCapture)) { + setManualCodeOk(false); + setManualCodeMsg(t("prov.targetChanged")); + return; + } if (!res.ok) { const data = await res.json().catch(() => ({})) as { error?: string }; setManualCodeOk(false); - setManualCodeMsg(t("prov.pasteFail", { error: data.error || res.statusText })); + setManualCodeMsg(data.error === "target_changed" + ? t("prov.targetChanged") + : t("prov.pasteFail", { error: data.error || res.statusText })); return; } setManualCode(""); @@ -331,7 +350,8 @@ export default function ProviderAuthPanel({ setManualCodeBusy(false); } }; - const loggedIn = accounts.length > 0 || oauth?.loggedIn === true; + const loggedIn = (accountLoadState === "ready" && accounts.length > 0) || oauth?.loggedIn === true; + const canLogout = loggedIn || (accountLoadState === "error" && oauth?.loggedIn !== false); const activeReauthAccount = accounts.find(a => a.active && a.needsReauth); const activeNeedsReauth = Boolean(activeReauthAccount); const quotaRows = isOauth ? accounts : keys; @@ -355,6 +375,7 @@ export default function ProviderAuthPanel({ setImportBusy(true); setImportStatus("idle"); setImportResult(null); + const capture = renderCapture; try { if (!file.name.toLowerCase().endsWith(".json") || file.size > COCKPIT_IMPORT_MAX_BYTES) { setImportStatus("invalid"); @@ -367,7 +388,7 @@ export default function ProviderAuthPanel({ setImportStatus("invalid"); return; } - const response = await fetch(`${apiBase}/api/oauth/accounts/import`, { + const response = await targetFetch(capture, `${apiBase}/api/oauth/accounts/import`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: "google-antigravity", format: "cockpit-tools", document }), @@ -444,6 +465,7 @@ export default function ProviderAuthPanel({ { void authHandlers?.onAccountPoolThreshold?.(item.name, threshold); }} @@ -488,7 +510,7 @@ export default function ProviderAuthPanel({ {loggedIn ? t("pws.loggedInTitle") - : (oauth?.error || t("pws.notLoggedInTitle"))} + : (oauth?.error || (oauth?.loggedIn === false ? t("pws.notLoggedInTitle") : t("startup.unverified")))} {activeReauthAccount && ( @@ -496,7 +518,7 @@ export default function ProviderAuthPanel({ {t("pws.reauthenticate")} )} - {loggedIn ? ( + {canLogout ? ( ) : (
@@ -465,24 +506,34 @@ export default function ProviderDetails({ onCancel={() => { if (!leaveSaving) setPendingLeave(null); }} onDiscard={() => { if (leaveSaving) return; - const next = pendingLeave; + const pending = pendingLeave; setPendingLeave(null); setSettingsDirty(false); - if (next === "deselect") onDeselect(); - else setTab(next); + // The confirmation belongs to the target that opened it. A stale confirmation + // must not clear the new target's draft or navigate under it. + if (!pending || pending.targetKey !== settingsTargetKey) return; + // Discard means the parked draft must not come back when Settings is reopened. + if (settingsDiscardRef.current) settingsDiscardRef.current(); + else localDraftStore.clear(pending.targetKey, item.name); + if (pending.next === "deselect") onDeselect(); + else setTab(pending.next); }} onSave={() => { void (async () => { if (leaveSaving) return; + const pending = pendingLeave; + if (!pending || pending.targetKey !== settingsTargetKey) { + setPendingLeave(null); + return; + } setLeaveSaving(true); try { const ok = await settingsSaveRef.current?.() ?? false; if (!ok) return; - const next = pendingLeave; setPendingLeave(null); setSettingsDirty(false); - if (next === "deselect") onDeselect(); - else if (next) setTab(next); + if (pending.next === "deselect") onDeselect(); + else setTab(pending.next); } finally { setLeaveSaving(false); } diff --git a/gui/src/components/provider-workspace/ProviderJsonEditor.tsx b/gui/src/components/provider-workspace/ProviderJsonEditor.tsx index 4f8bdaede35..7a0a3ce7b5a 100644 --- a/gui/src/components/provider-workspace/ProviderJsonEditor.tsx +++ b/gui/src/components/provider-workspace/ProviderJsonEditor.tsx @@ -13,16 +13,24 @@ export interface JsonEditorState { onSave: () => Promise; onClose: () => void; onRestore?: () => void; + /** Saved values moved under this draft; the user must choose a side before saving. */ + conflict?: boolean; + /** Paths both sides changed; the user must edit the draft or use saved values. */ + conflicts?: string[]; + onUseSaved?: () => void; + onKeepDraft?: () => void; } export default function ProviderJsonEditor({ - editor, providerName, saving, onSave, message, + editor, providerName, saving, onSave, message, targetLabel, }: { editor: JsonEditorState; providerName: string; saving: boolean; onSave: () => void; message?: { ok: boolean; text: string } | null; + /** Dashboard server this editor writes to; shown so the save target is explicit. */ + targetLabel?: string; }) { const t = useT(); const textareaRef = useRef(null); @@ -42,12 +50,28 @@ export default function ProviderJsonEditor({ )} -

{t("pws.jsonEditorDesc")}

+ {targetLabel &&

{t("pws.draftTarget", { target: targetLabel })}

} + {editor.conflict && ( +
+

{t("pws.draftConflict")}

+ {editor.conflicts && editor.conflicts.length > 0 && ( +
+

{t("pws.draftConflictFields")}

+
    {editor.conflicts.map(path =>
  • {path}
  • )}
+
+ )} +
+ + +
+
+ )}