diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 565864ffbec..4ee66a405a2 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -17,7 +17,7 @@ opencodex 的 CLI 是 `ocx`。它会根据第一个命令名进行分发;文 管理命令会通过实时代理的管理 API 往返调用,使用记录下来的运行时端口和身份检查,而不是维护第二条配置路径。已停止或不可达的代理会被表示为 HTTP 503,并导致 CLI 以非零状态退出。明确标注为离线配置操作的命令,则可以在没有实时代理的情况下验证并编辑配置文件。 -`check` 不查询注册表,也不运行 Codex。在 POSIX 上,当已发布启动器捕获的环境候选项与运行时解析器选择的规范路径一致时,`selectionAttested` 可以为 `true`;如果关联的 npm-global 软件包清单证明了所有权,`managed` 可以为 `true`。持久化候选项仍不获认证。Windows x64 还支持经证明绑定的绝对路径标准 npm `codex.cmd`,以及由持有句柄绑定的 Node/npm 工具链(存在时为前缀本地,确认不存在时为锚定到启动器架构的已捕获 PATH 对)和已验证的前缀内平台支持组件,并使用拒绝重解析的持有句柄。其他 Windows 布局仍然推迟或不受管理。Windows 的 `managed` 结果仅对观察到的安装布局进行分类:绑定的 Node/npm 是更新器的只读元数据工具链,并不保证后续 `codex.cmd` 调用将解析到哪个解释器。直接通过 Bun 或源码运行时没有启动器证明。`plan` 使用隔离的临时 npm 配置、缓存和日志查询官方注册表,并拒绝活动会话或不完整的进程观测。它不安装软件,也不向进程发送信号。Windows x64 的 apply 要求具有经过验证的私有写入权限的受限 npm 前缀,并保留无关的软件包和 shim;不受支持的已观测布局返回 `applicable: false` 且 `refusal: windows_apply_disabled`;仅 PATH 选择、OpenCodex 自启动包装器和 global/vendor 回退仍然推迟。请参阅[完整命令和拒绝原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 +`check` 不查询注册表,也不运行 Codex。在 POSIX 上,当已发布启动器捕获的环境候选项与运行时解析器选择的规范路径一致时,`selectionAttested` 可以为 `true`;如果关联的 npm-global 软件包清单证明了所有权,并且在报告了候选版本时该版本与清单版本一致,`managed` 可以为 `true`。持久化候选项仍不获认证。Windows x64 还支持经证明绑定的绝对路径标准 npm `codex.cmd`,以及由持有句柄绑定的 Node/npm 工具链(存在时为前缀本地,确认不存在时为锚定到启动器架构的已捕获 PATH 对)和已验证的前缀内平台支持组件,并使用拒绝重解析的持有句柄。其他 Windows 布局仍然推迟或不受管理。Windows 的 `managed` 结果仅对观察到的安装布局进行分类:绑定的 Node/npm 是更新器的只读元数据工具链,并不保证后续 `codex.cmd` 调用将解析到哪个解释器。直接通过 Bun 或源码运行时没有启动器证明。`plan` 使用隔离的临时 npm 配置、缓存和日志查询官方注册表,并拒绝活动会话或不完整的进程观测。它不安装软件,也不向进程发送信号。Windows x64 的 apply 要求具有经过验证的私有写入权限的受限 npm 前缀,并保留无关的软件包和 shim;不受支持的已观测布局返回 `applicable: false` 且 `refusal: windows_apply_disabled`;仅 PATH 选择、OpenCodex 自启动包装器和 global/vendor 回退仍然推迟。请参阅[完整命令和拒绝原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 有关 Windows x64 安装观察,请参阅 [`attest` 命令](/zh-cn/reference/cli/agents/)。未提供显式路径时,该命令观察由受证明约束的启动器快照识别出的已选候选项;它不授予更新权限,也不证明运行时选择。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 4fcd40764b5..ef7917dc81e 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -255,7 +255,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` 不查询注册表,也不运行 Codex。在 POSIX 上,当已发布启动器捕获的环境候选项与运行时解析器选择的规范路径一致时,`selectionAttested` 可以为 `true`;如果关联的 npm-global 软件包清单证明了所有权,`managed` 可以为 `true`。持久化候选项仍不获认证。Windows x64 还支持经证明绑定的绝对路径标准 npm `codex.cmd`,以及由持有句柄绑定的 Node/npm 工具链(存在时为前缀本地,确认不存在时为锚定到启动器架构的已捕获 PATH 对)和已验证的前缀内平台支持组件,并使用拒绝重解析的持有句柄。其他 Windows 布局仍然推迟或不受管理。Windows 的 `managed` 结果仅对观察到的安装布局进行分类:绑定的 Node/npm 是更新器的只读元数据工具链,并不保证后续 `codex.cmd` 调用将解析到哪个解释器。直接通过 Bun 或源码运行时没有启动器证明。`plan` 使用隔离的临时 npm 配置、缓存和日志查询官方注册表,并拒绝活动会话或不完整的进程观测。它不安装软件,也不向进程发送信号。Windows x64 的 apply 要求具有经过验证的私有写入权限的受限 npm 前缀,并保留无关的软件包和 shim;不受支持的已观测布局返回 `applicable: false` 且 `refusal: windows_apply_disabled`;仅 PATH 选择、OpenCodex 自启动包装器和 global/vendor 回退仍然推迟。请参阅[完整命令和拒绝原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 +`check` 不查询注册表,也不运行 Codex。在 POSIX 上,当已发布启动器捕获的环境候选项与运行时解析器选择的规范路径一致时,`selectionAttested` 可以为 `true`;如果关联的 npm-global 软件包清单证明了所有权,并且在报告了候选版本时该版本与清单版本一致,`managed` 可以为 `true`。持久化候选项仍不获认证。Windows x64 还支持经证明绑定的绝对路径标准 npm `codex.cmd`,以及由持有句柄绑定的 Node/npm 工具链(存在时为前缀本地,确认不存在时为锚定到启动器架构的已捕获 PATH 对)和已验证的前缀内平台支持组件,并使用拒绝重解析的持有句柄。其他 Windows 布局仍然推迟或不受管理。Windows 的 `managed` 结果仅对观察到的安装布局进行分类:绑定的 Node/npm 是更新器的只读元数据工具链,并不保证后续 `codex.cmd` 调用将解析到哪个解释器。直接通过 Bun 或源码运行时没有启动器证明。`plan` 使用隔离的临时 npm 配置、缓存和日志查询官方注册表,并拒绝活动会话或不完整的进程观测。它不安装软件,也不向进程发送信号。Windows x64 的 apply 要求具有经过验证的私有写入权限的受限 npm 前缀,并保留无关的软件包和 shim;不受支持的已观测布局返回 `applicable: false` 且 `refusal: windows_apply_disabled`;仅 PATH 选择、OpenCodex 自启动包装器和 global/vendor 回退仍然推迟。请参阅[完整命令和拒绝原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 在 Windows 上,如果捕获到 `CODEX_CLI_PATH=codex` 这样的裸命令、远程路径或设备路径,则报告 `candidate_path_unavailable`。这些情况下候选项已被捕获,但其路径不适用于此检查。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli.md b/docs-site/src/content/docs/zh-tw/reference/cli.md index 4380b21d358..56909f99bcf 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli.md @@ -28,7 +28,7 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 設定路徑。停止或無法連線的代理以 HTTP 503 呈現,並產生非零的 CLI 離開碼。明確記載為 離線設定操作的命令,可以在沒有執行中代理的情況下驗證與編輯設定檔。 -`check` 不查詢註冊表,也不執行 Codex。在 POSIX 上,當已發布啟動器擷取的環境候選項與執行階段解析器選擇的正規路徑相同時,`selectionAttested` 可以為 `true`;若關聯的 npm-global 套件資訊清單證明了所有權,`managed` 可以為 `true`。持久化候選項仍不獲認證。Windows x64 也支援經證明繫結的絕對路徑標準 npm `codex.cmd`,以及由持有控制代碼繫結的 Node/npm 工具鏈(存在時為前綴本機,確認不存在時為錨定到啟動器架構的已擷取 PATH 配對)和已驗證的前綴內平台支援元件,並使用拒絕重新剖析的持有控制代碼。其他 Windows 配置仍然延後或不受管理。Windows 的 `managed` 結果僅對觀測到的安裝配置進行分類:繫結的 Node/npm 是更新器的唯讀中繼資料工具鏈,並不保證後續 `codex.cmd` 呼叫將解析到哪個直譯器。直接透過 Bun 或原始碼執行時沒有啟動器證明。`plan` 使用隔離的暫存 npm 設定、快取和日誌查詢官方註冊表,並拒絕活動工作階段或不完整的程序觀測。它不安裝軟體,也不向程序傳送訊號。Windows x64 的 apply 需要具備經過驗證的私人寫入權限的受限 npm 前綴,並保留無關的套件和 shim;不受支援的已觀測配置回傳 `applicable: false` 且 `refusal: windows_apply_disabled`;僅 PATH 選取、OpenCodex 自動啟動包裝器和 global/vendor 後援仍然延後。請參閱[完整命令與拒絕原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 +`check` 不查詢註冊表,也不執行 Codex。在 POSIX 上,當已發布啟動器擷取的環境候選項與執行階段解析器選擇的正規路徑相同時,`selectionAttested` 可以為 `true`;若關聯的 npm-global 套件資訊清單證明了所有權,且在回報候選版本時該版本與資訊清單版本一致,`managed` 可以為 `true`。持久化候選項仍不獲認證。Windows x64 也支援經證明繫結的絕對路徑標準 npm `codex.cmd`,以及由持有控制代碼繫結的 Node/npm 工具鏈(存在時為前綴本機,確認不存在時為錨定到啟動器架構的已擷取 PATH 配對)和已驗證的前綴內平台支援元件,並使用拒絕重新剖析的持有控制代碼。其他 Windows 配置仍然延後或不受管理。Windows 的 `managed` 結果僅對觀測到的安裝配置進行分類:繫結的 Node/npm 是更新器的唯讀中繼資料工具鏈,並不保證後續 `codex.cmd` 呼叫將解析到哪個直譯器。直接透過 Bun 或原始碼執行時沒有啟動器證明。`plan` 使用隔離的暫存 npm 設定、快取和日誌查詢官方註冊表,並拒絕活動工作階段或不完整的程序觀測。它不安裝軟體,也不向程序傳送訊號。Windows x64 的 apply 需要具備經過驗證的私人寫入權限的受限 npm 前綴,並保留無關的套件和 shim;不受支援的已觀測配置回傳 `applicable: false` 且 `refusal: windows_apply_disabled`;僅 PATH 選取、OpenCodex 自動啟動包裝器和 global/vendor 後援仍然延後。請參閱[完整命令與拒絕原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 觀測 Windows x64 安裝的方式請參閱 [`attest` 命令](/zh-tw/reference/cli/agents/)。未提供明確路徑時,該命令會觀測由受證明約束的啟動器快照識別出的已選候選項;它不授予更新權限,也不證明執行階段選擇。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 81bddf0c8ee..cfdd4070fb4 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -256,7 +256,7 @@ ocx system settings --stream-mode eager-relay ocx system codex-cli-update check --json ``` -`check` 不查詢註冊表,也不執行 Codex。在 POSIX 上,當已發布啟動器擷取的環境候選項與執行階段解析器選擇的正規路徑相同時,`selectionAttested` 可以為 `true`;若關聯的 npm-global 套件資訊清單證明了所有權,`managed` 可以為 `true`。持久化候選項仍不獲認證。Windows x64 也支援經證明繫結的絕對路徑標準 npm `codex.cmd`,以及由持有控制代碼繫結的 Node/npm 工具鏈(存在時為前綴本機,確認不存在時為錨定到啟動器架構的已擷取 PATH 配對)和已驗證的前綴內平台支援元件,並使用拒絕重新剖析的持有控制代碼。其他 Windows 配置仍然延後或不受管理。Windows 的 `managed` 結果僅對觀測到的安裝配置進行分類:繫結的 Node/npm 是更新器的唯讀中繼資料工具鏈,並不保證後續 `codex.cmd` 呼叫將解析到哪個直譯器。直接透過 Bun 或原始碼執行時沒有啟動器證明。`plan` 使用隔離的暫存 npm 設定、快取和日誌查詢官方註冊表,並拒絕活動工作階段或不完整的程序觀測。它不安裝軟體,也不向程序傳送訊號。Windows x64 的 apply 需要具備經過驗證的私人寫入權限的受限 npm 前綴,並保留無關的套件和 shim;不受支援的已觀測配置回傳 `applicable: false` 且 `refusal: windows_apply_disabled`;僅 PATH 選取、OpenCodex 自動啟動包裝器和 global/vendor 後援仍然延後。請參閱[完整命令與拒絕原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 +`check` 不查詢註冊表,也不執行 Codex。在 POSIX 上,當已發布啟動器擷取的環境候選項與執行階段解析器選擇的正規路徑相同時,`selectionAttested` 可以為 `true`;若關聯的 npm-global 套件資訊清單證明了所有權,且在回報候選版本時該版本與資訊清單版本一致,`managed` 可以為 `true`。持久化候選項仍不獲認證。Windows x64 也支援經證明繫結的絕對路徑標準 npm `codex.cmd`,以及由持有控制代碼繫結的 Node/npm 工具鏈(存在時為前綴本機,確認不存在時為錨定到啟動器架構的已擷取 PATH 配對)和已驗證的前綴內平台支援元件,並使用拒絕重新剖析的持有控制代碼。其他 Windows 配置仍然延後或不受管理。Windows 的 `managed` 結果僅對觀測到的安裝配置進行分類:繫結的 Node/npm 是更新器的唯讀中繼資料工具鏈,並不保證後續 `codex.cmd` 呼叫將解析到哪個直譯器。直接透過 Bun 或原始碼執行時沒有啟動器證明。`plan` 使用隔離的暫存 npm 設定、快取和日誌查詢官方註冊表,並拒絕活動工作階段或不完整的程序觀測。它不安裝軟體,也不向程序傳送訊號。Windows x64 的 apply 需要具備經過驗證的私人寫入權限的受限 npm 前綴,並保留無關的套件和 shim;不受支援的已觀測配置回傳 `applicable: false` 且 `refusal: windows_apply_disabled`;僅 PATH 選取、OpenCodex 自動啟動包裝器和 global/vendor 後援仍然延後。請參閱[完整命令與拒絕原因(英文)](/reference/cli/agents/#read-only-codex-cli-update-plan)。 在 Windows 上,如果擷取到 `CODEX_CLI_PATH=codex` 這類單純命令名稱、遠端路徑或裝置路徑,則回報 `candidate_path_unavailable`。這些情況已有擷取的候選項,但其路徑不適用於此檢查。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index eab132c488c..f32c120378d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -481,7 +481,7 @@ "cli-api-protocols.test.ts": "cli", "cli-catalog-prewarm.test.ts": "cli", "cli-ensure-readiness.test.ts": "cli", - "cli-codex-cli-update-boundary.test.ts": "cli", "windows-codex-cli-job-store.test.ts": "windows", "codex-cli-update-windows-apply.test.ts": "codex-integration", "codex-cli-update-windows-recovery.test.ts": "codex-integration", + "cli-codex-cli-update-boundary.test.ts": "cli", "windows-codex-cli-job-store.test.ts": "windows", "codex-cli-update-windows-apply.test.ts": "codex-integration", "codex-cli-update-windows-recovery.test.ts": "codex-integration", "codex-cli-update-windows-review.test.ts": "codex-integration", "cli-codex-cli-update.test.ts": "cli", "cli-codex-log-guard-compact.test.ts": "cli", "cli-codex-log-guard-protection.test.ts": "cli", diff --git a/src/codex/cli-update-job-store-windows.ts b/src/codex/cli-update-job-store-windows.ts index 536c48acdfd..9444bff57f2 100644 --- a/src/codex/cli-update-job-store-windows.ts +++ b/src/codex/cli-update-job-store-windows.ts @@ -55,7 +55,7 @@ * empirical cross-user proof. */ import { createHash, randomUUID } from "node:crypto"; -import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { closeSync, fsyncSync, mkdirSync, openSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { basename, join, resolve, toNamespacedPath } from "node:path"; import type { Library, Symbols } from "bun:ffi"; import * as recoveryFfi from "bun:ffi"; @@ -656,7 +656,8 @@ async function acquireLock(ctx: Ctx, prepared: Prepared): Promise { * while its pin shows this call's identity. A replaced owner or lock survives, * which keeps admission fail-closed on the leftover. */ -function releaseLock(ctx: Ctx, held: HeldLock): void { +function releaseLock(ctx: Ctx, held: HeldLock): boolean { + let ownerDeleted = false, dirDeleted = false; try { const now = inspect(ctx, held.owner, false); if (sameIdentity(now, held.ownerIdentity) && now.links === 1 @@ -664,13 +665,18 @@ function releaseLock(ctx: Ctx, held: HeldLock): void { && now.size <= 1024 && readAll(ctx, held.owner, now.size).equals(Buffer.from(held.record))) { markDelete(ctx, held.owner); + ownerDeleted = true; } } catch { /* fail closed; a leftover owner keeps the lock alive */ } ctx.pins.release(held.owner, ctx.kernel); try { - if (sameIdentity(inspect(ctx, held.dir, true), held.dirIdentity)) markDelete(ctx, held.dir); + if (sameIdentity(inspect(ctx, held.dir, true), held.dirIdentity)) { + markDelete(ctx, held.dir); + dirDeleted = true; + } } catch { /* fail closed; a leftover lock blocks later admission */ } ctx.pins.release(held.dir, ctx.kernel); + return ownerDeleted && dirDeleted; } export interface WindowsCliRecoveryIo { @@ -779,6 +785,7 @@ export async function openWindowsCliApplyAuthority(prefix: string, acquire: bool let held: HeldLock | undefined; let transferred = false; let closed = false; + let resourcesReleased = false; try { // Publish the context before the first pin/open or async verification can fail. // The caller already holds its Worker token; failed cleanup remains rooted there. @@ -820,6 +827,8 @@ export async function openWindowsCliApplyAuthority(prefix: string, acquire: bool return { lock, verify, + /** Disposal proof is independent of the ACL diagnostic returned by release(). */ + get resourcesReleased() { return resourcesReleased; }, verifyIdentitySync() { if (closed) throw unsafe(); verifyIdentitySync(); }, async createStaging(): Promise { if (closed || !held || stage) throw unsafe(); @@ -847,9 +856,20 @@ export async function openWindowsCliApplyAuthority(prefix: string, acquire: bool async release(): Promise { if (closed) return false; closed = true; - try { await verifyHeld(); if (held) releaseLock(ctx, held); } + let aclVerified = true; + let lockDeleted = !acquire; + try { + verifyIdentitySync(); + // An ACL lookup failure must not strand a lock whose original pins + // still prove ownership. It remains a failed release verdict, even + // when the independently token/identity-fenced deletion succeeds. + try { await verifyAcl(entries); } catch { aclVerified = false; } + verifyIdentitySync(); + if (held) lockDeleted = releaseLock(ctx, held); + } finally { ctx.pins.closeAll(kernel); } - return pins.remaining === 0 && (!acquire || !existsSync(lock)); + resourcesReleased = pins.remaining === 0 && !stage && lockDeleted; + return aclVerified && resourcesReleased; }, }; } finally { if (!transferred) ctx.pins.closeAll(kernel); } diff --git a/src/codex/cli-update-windows-apply.ts b/src/codex/cli-update-windows-apply.ts index 151b177d231..aa42e37062a 100644 --- a/src/codex/cli-update-windows-apply.ts +++ b/src/codex/cli-update-windows-apply.ts @@ -156,7 +156,8 @@ export async function applyWindowsCodexCliUpdate(planId: string, deps: CodexCliA await flushWindowsSecretAclReapsBeforeRemoval(owner.staging); await owner.authority.removeStaging(); if (owner.toolchain && !await owner.toolchain.release()) return false; - if (!await owner.authority.release()) return false; + await owner.authority.release(); + if (!owner.authority.resourcesReleased) return false; retained.delete(owner); owner.retention?.release(); return true; } catch { return false; } finally { recovering = false; } }; @@ -287,16 +288,20 @@ export async function applyWindowsCodexCliUpdate(planId: string, deps: CodexCliA if (installationWindowHeld && !uncertain && !cleanupPending) deps.commitWindow!.exit(); if (owner && !uncertain && !cleanupPending) { const errors: CodexCliApplyCleanupError[] = []; + let resourcesReleased = true; try { await flushWindowsSecretAclReapsBeforeRemoval(owner.staging); await owner.authority.removeStaging(); - } catch { errors.push("staging_cleanup_failed"); } - try { if (owner.toolchain && !await owner.toolchain.release()) errors.push("lease_release_unverified"); } - catch { errors.push("lease_release_exception"); } + } catch { errors.push("staging_cleanup_failed"); resourcesReleased = false; } + try { if (owner.toolchain && !await owner.toolchain.release()) { + errors.push("lease_release_unverified"); resourcesReleased = false; + } } + catch { errors.push("lease_release_exception"); resourcesReleased = false; } try { if (!await owner.authority.release()) errors.push("lease_release_unverified"); } catch { errors.push("lease_release_exception"); } - if (!errors.length) { retained.delete(owner); ownerRegistrationPending = false; owner.retention?.release(); } - if (output) Object.assign(output, { cleanupErrors: errors, leaseRetained: errors.length > 0 }); + resourcesReleased &&= owner.authority.resourcesReleased; + if (resourcesReleased) { retained.delete(owner); ownerRegistrationPending = false; owner.retention?.release(); } + if (output) Object.assign(output, { cleanupErrors: errors, leaseRetained: !resourcesReleased }); } if (output) { Object.freeze(output.cleanupErrors); Object.freeze(output); } } diff --git a/src/codex/cli-update-windows-observation.ts b/src/codex/cli-update-windows-observation.ts index 621a57fe687..c309b6b67a7 100644 --- a/src/codex/cli-update-windows-observation.ts +++ b/src/codex/cli-update-windows-observation.ts @@ -24,11 +24,11 @@ export const windowsCodexOwnedPath = (relative: string): boolean => roots.slice( function inventory(prefix: string) { const paths: string[] = [], directories: string[] = [], acl: WindowsAclEntry[] = []; const validate = windowsArchivePathValidator(); - let entries = 0, total = 0; + let entries = 0, total = 0n; const walk = (relative: string) => { if (++entries > 4096) throw new Error("installation_readback_limit"); - const path = win32.join(prefix, relative), st = lstatSync(path); - if (st.isSymbolicLink() || (!st.isDirectory() && !st.isFile()) || (!st.isDirectory() && st.nlink !== 1)) { + const path = win32.join(prefix, relative), st = lstatSync(path, { bigint: true }); + if (st.isSymbolicLink() || (!st.isDirectory() && !st.isFile()) || (!st.isDirectory() && st.nlink !== 1n)) { throw new Error("installation_path_unverified"); } validate(relative, st.isDirectory()); @@ -40,7 +40,7 @@ function inventory(prefix: string) { for (const name of readdirSync(path).sort()) walk(`${relative}/${name}`); } else { total += st.size; - if (st.size > MAX_CODEX_MEMBER_BYTES || total > MAX_CODEX_UNPACKED_BYTES) throw new Error("installation_readback_limit"); + if (st.size > BigInt(MAX_CODEX_MEMBER_BYTES) || total > BigInt(MAX_CODEX_UNPACKED_BYTES)) throw new Error("installation_readback_limit"); paths.push(relative); } }; diff --git a/structure/INDEX.md b/structure/INDEX.md index 909e73fc0df..64a46707c70 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -272,6 +272,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/cli-update-windows-paths.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-worker-recovery.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/cli-update-worker.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/cli-windows-plan-provenance.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/provider-routing.ts` | [`codex-provider-routing.md`](codex-provider-routing.md) | | `src/codex/quota.ts` | [`quota-snapshots.md`](quota-snapshots.md) | | `src/codex/runtime.ts` | [`catalog-effort-clamp.md`](catalog-effort-clamp.md) | @@ -282,6 +283,8 @@ A source area can be described by more than one doc, because these docs are orga | `src/codex/windows-cli-child.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/windows-cli-recovery-guardian.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/codex/windows-cli-recovery.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/windows-installation-files.ts` | [`codex-cli-update.md`](codex-cli-update.md) | +| `src/codex/windows-npm-execution.ts` | [`codex-cli-update.md`](codex-cli-update.md) | | `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`companion.md`](companion.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | @@ -308,7 +311,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/lib/gui-pair-intent.ts` | [`remote-link.md`](remote-link.md) | | `src/lib/local-upstream.ts` | [`transports/timeouts.md`](transports/timeouts.md) | | `src/lib/tool-argument-integers.ts` | [`providers-and-adapters.md`](providers-and-adapters.md) | -| `src/lib/windows-owner-acl.ts` | [`remote-link.md`](remote-link.md) | +| `src/lib/windows-owner-acl.ts` | [`codex-cli-update.md`](codex-cli-update.md)
[`remote-link.md`](remote-link.md) | | `src/link/` | [`remote-messaging.md`](remote-messaging.md)
[`remote-link.md`](remote-link.md) | | `src/local-credentials/` | [`remote-link.md`](remote-link.md) | | `src/messaging/` | [`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md) | diff --git a/structure/codex-cli-update.md b/structure/codex-cli-update.md index acac60cd64f..435db9bcbbf 100644 --- a/structure/codex-cli-update.md +++ b/structure/codex-cli-update.md @@ -338,7 +338,11 @@ keeps the run-once handler installed. Only successful terminal cleanup removes t its token. No polling, automatic retry/reap or termination follows from retention; a retained realm can remain resident until separately authorized recovery. A crash still loses in-memory ownership and leaves durable state unknown. The pure lifetime test is not native disposal evidence. -Authority release succeeds only when lock disposal is verified and its pin set is empty. +Authority disposal is proved only after identity/token-fenced lock removal, absent staging and +an empty pin set. Its `resourcesReleased` evidence is separate from release's ACL diagnostic. +Disposal follows the original held objects' deletion, even if the pathname is unreadable or replaced. +Apply preserves that diagnostic in cleanup errors, but releases the execution token once staging, +toolchain and authority disposal are all proved, including during terminal recovery. A failed CloseHandle stays in the apply-only `WindowsApplyAuthorityPins` set; its closeAll skips failed closes instead of retrying or forgetting them, preserving Owner/token with `lease_release_unverified`. The ordinary ledger/status PinSet keeps its pre-existing best-effort disposal contract; this change @@ -371,7 +375,9 @@ cleanup a spawn throw runs, so a rejected bridge stop cannot skip isolation remo `src/codex/desktop-app-restart.ts` (busy returns the new `selection_mutation_busy` skipped reason before the restart lock, package discovery, any app quit and the handoff, so a queued relaunch — and its own lock-acquisition failure — cannot slip inside the window). Writers never wait: the commit -window spans the final process scan through terminal installer evidence and readback. Windows also +window spans the final process scan through terminal installer evidence and readback. Windows directory +observations retain bigint device/inode identities, including the second namespace check after +asynchronous ACL/file observations. Windows also renews full authority ACL/identity, inspector and full scoped preimage after acquisition, then performs the final process scan, synchronous held-identity/selection/cancellation checks, CAS and synchronous native Create/Assign/Resume with no intervening await. Windows retains the gate through native Job @@ -463,6 +469,14 @@ documented residual it always was. On Windows, `src/codex/cli-update-job-store-windows.ts` maps the same ledger contract onto NTFS handle semantics and the public admit/read/finish API in `src/codex/cli-update-job.ts` dispatches to it asynchronously. Each ancestor from the volume root is opened relative to its already-held parent — never a re-parsed path — and refused on any reparse attribute, then evaluated under the "trusted-ancestry" ACL policy (owner is the serving token or SYSTEM/Administrators/TrustedInstaller; untrusted grantees may hold read and, on admin-owned ancestors only, broad create-only rights). The config-root parent and a pre-existing config root are verified under the "write-private" policy and are never rehardened; only a directory the call itself creates is icacls-hardened and held to the strict "private" rule. File identity is the volume serial plus 128-bit file ID with single link and no pending delete, the dev:ino analog. Ledger writes go through a private hardened temp that is byte/ACL/identity-verified, renamed while its handle is held, re-read through the published name, and flushed at the directory. The admission mutex is a mkdir lock plus an owner record released only while both held pins still show this call identity and content, so a leftover lock blocks later admission exactly like POSIX. ACL snapshots are taken per phase through the shared bounded PowerShell read in `src/lib/windows-owner-acl.ts` (async variant), not per path. Pins bind each object to its verified state for the operation and the private DACL confines writers, but this is not a proof against concurrent mutation by the serving account or an elevated process, which sit inside the trust boundary as on POSIX. The backend is x64-only; cross-account (other-SID) and ARM64 behavior are unverified. +Apply-authority disposal requires successful identity/token-fenced delete-on-close of both original +lock objects, zero remaining pins and no staging. A replacement at the pathname neither proves nor +prevents this owner's disposal; failed deletion or close retains its Worker execution token. +Apply-authority release checks held identity before and after its ACL read. An ACL-only failure +still attempts deletion through the original token-fenced pins and reports a failed release verdict. +That verdict alone does not retain the Worker: proved resource disposal permits closure and a later apply. +Identity failure preserves the lock while closing pins. Repeated release cannot affect a later owner. + Each successful handle-relative `relOpen` registers its handle exactly once before returning it; callers use that already-owned handle without wrapping it in another `keep` registration. This applies to config/store pins, read/write/published leaves and lock/owner pins, so disposal diff --git a/structure/manifest.json b/structure/manifest.json index 9fa23803f1e..e9f91347b63 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -206,6 +206,10 @@ "src/codex/cli-update-apply.ts", "src/codex/cli-update-windows-apply.ts", "src/codex/cli-update-windows-observation.ts", + "src/codex/cli-windows-plan-provenance.ts", + "src/codex/windows-installation-files.ts", + "src/codex/windows-npm-execution.ts", + "src/lib/windows-owner-acl.ts", "src/codex/cli-update-job.ts", "src/codex/cli-update-job-execution.ts", "src/codex/cli-update-job-store-windows.ts", diff --git a/tests/codex-integration/codex-cli-update-windows-apply-install.test.ts b/tests/codex-integration/codex-cli-update-windows-apply-install.test.ts index 472e33cebe3..c919816b85f 100644 --- a/tests/codex-integration/codex-cli-update-windows-apply-install.test.ts +++ b/tests/codex-integration/codex-cli-update-windows-apply-install.test.ts @@ -2,16 +2,19 @@ import { windowsTempAncestryPreload } from "../helpers/windows-temp-ancestry"; import { expect } from "bun:test"; import { copyFileSync, mkdirSync, readFileSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; +import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; import { applyCodexCliUpdate } from "../../src/codex/cli-update-apply"; import { startWindowsCliChild } from "../../src/codex/windows-cli-child"; import { createCodexCliUpdateFence, cancelCodexCliUpdateFence, createWorkerCommitWindow, codexCliUpdateIsCancelled, allocateCodexCliUpdateGateOwner } from "../../src/codex/cli-update-selection-gate"; -import { createWindowsApplyFixtures, native, WINDOWS_APPLY_BUDGET_MS, fixtureCleanupProven, type WorkerFixtureReply, mainRoot, backingRoot, write } from "../helpers/codex-cli-windows-apply-fixture"; +import { createWindowsApplyFixtures, native, WINDOWS_APPLY_BUDGET_MS, fixtureCleanupProven, type WorkerFixtureReply, artifacts, mainRoot, backingRoot, write } from "../helpers/codex-cli-windows-apply-fixture"; const { liveFixtureRoots, fixture, approved } = createWindowsApplyFixtures(); -native("a fresh Worker runs real offline Windows FFI install and closes only after native settlement", async () => { +native.each([false, true])("a fresh Worker closes after offline Windows apply, ACL release failure=%s", async aclReleaseFailure => { let root: string | undefined, closed = false, errored = false, worker: Worker | undefined; + let followupClean = !aclReleaseFailure; const requestId = crypto.randomUUID(), messages: WorkerFixtureReply[] = []; + const cleanupErrors = aclReleaseFailure ? ["lease_release_unverified"] : []; try { const f = await fixture(undefined, created => { root = created; liveFixtureRoots.add(created); }); const id = await approved(f); @@ -22,20 +25,76 @@ native("a fresh Worker runs real offline Windows FFI install and closes only aft worker!.addEventListener("error", () => { errored = true; reject(new Error("offline Worker failed")); }, { once: true }); worker!.addEventListener("close", () => { closed = true; resolve(); }, { once: true }); }); + const replied = new Promise(resolve => { + worker!.addEventListener("message", event => { + if (["result", "fixture-error"].includes(event.data?.type)) resolve(event.data); + }); + }); const gate = new SharedArrayBuffer(8), fence = createCodexCliUpdateFence(); - worker.postMessage({ requestId, planId: id, inspectionDeps: f.deps.inspectionDeps, target: f.target, gate, fence }); + worker.postMessage({ requestId, planId: id, inspectionDeps: f.deps.inspectionDeps, target: f.target, gate, fence, aclReleaseFailure }); + // Assert the result before joining: the old bug keeps the Worker's handler alive. + const reply = await Promise.race([replied, joined.then(() => { throw new Error("Worker closed without fixture result"); })]); + expect(reply.result).toMatchObject({ outcome: "applied", reason: "verified_target", leaseRetained: false, cleanupErrors }); await joined; expect(messages.filter(m => m.type === "native-start")).toHaveLength(1); expect(messages.filter(m => m.type === "fixture-error")).toHaveLength(0); const results = messages.filter(m => m.type === "result"); expect(results).toHaveLength(1); - expect(results[0]!.result).toMatchObject({ outcome: "applied", reason: "verified_target", leaseRetained: false, cleanupErrors: [] }); - expect(fixtureCleanupProven(messages, requestId, closed, errored)).toBe(true); + expect(results[0]!.result).toMatchObject({ outcome: "applied", reason: "verified_target", leaseRetained: false, cleanupErrors }); + expect(fixtureCleanupProven(messages, requestId, closed, errored, cleanupErrors)).toBe(true); expect(Atomics.load(new Int32Array(gate), 0)).toBe(0); expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + if (aclReleaseFailure) { + const next = artifacts("1.2.0"); + const deps = { ...f.deps, + resolveTarget: () => ({ kind: "resolved" as const, version: "1.2.0", integrity: next.set.main.integrity, artifacts: next.set }), + fetchArtifact: async (artifact: typeof next.set.main) => artifact.installName === "@openai/codex" ? next.main : next.backing }; + const plan = await createCodexCliUpdatePlan(deps); + expect(plan.applicable).toBe(true); + const result = await applyCodexCliUpdate(plan.planId!, deps); + followupClean = !result.leaseRetained && result.cleanupErrors.length === 0; + expect(result).toMatchObject({ outcome: "applied", reason: "verified_target", + installedVersion: "1.2.0", leaseRetained: false, cleanupErrors: [] }); + expect(existsSync(join(f.prefix, ".ocx-codex-cli-apply.lock"))).toBe(false); + } } finally { // A still-live native Worker keeps its exact fixture; never terminate to fake settlement. + if (root && followupClean && fixtureCleanupProven(messages, requestId, closed, errored, cleanupErrors)) liveFixtureRoots.delete(root); + // A failing regression must preserve its fixture without keeping the test host alive. + if (!closed) worker?.unref(); + } +}, WINDOWS_APPLY_BUDGET_MS); + +native("a replacement lock survives while the original offline apply Worker closes", async () => { + let root: string | undefined, closed = false, errored = false, worker: Worker | undefined; + const requestId = crypto.randomUUID(), messages: WorkerFixtureReply[] = []; + try { + const f = await fixture(undefined, created => { root = created; liveFixtureRoots.add(created); }); + const id = await approved(f); + worker = new Worker(new URL("../helpers/windows-apply-worker.ts", import.meta.url), + { type: "module", preload: windowsTempAncestryPreload(f.root) }); + const joined = new Promise((resolve, reject) => { + worker!.addEventListener("message", event => { messages.push(event.data); }); + worker!.addEventListener("error", () => { errored = true; reject(new Error("replacement fixture Worker failed")); }, { once: true }); + worker!.addEventListener("close", () => { closed = true; resolve(); }, { once: true }); + }); + const replied = new Promise(resolve => { + worker!.addEventListener("message", event => { + if (["result", "fixture-error"].includes(event.data?.type)) resolve(event.data); + }); + }); + const gate = new SharedArrayBuffer(8), fence = createCodexCliUpdateFence(); + worker.postMessage({ requestId, planId: id, inspectionDeps: f.deps.inspectionDeps, target: f.target, gate, fence, replaceLock: true }); + const reply = await Promise.race([replied, joined.then(() => { throw new Error("Worker closed without fixture result"); })]); + expect(reply.result).toMatchObject({ outcome: "applied", reason: "verified_target", leaseRetained: false, cleanupErrors: [] }); + await joined; + expect(fixtureCleanupProven(messages, requestId, closed, errored)).toBe(true); + expect(messages.filter(m => m.type === "native-start")).toHaveLength(1); + expect(Atomics.load(new Int32Array(gate), 0)).toBe(0); + expect(readFileSync(join(f.prefix, ".ocx-codex-cli-apply.lock", "owner.json"), "utf8")).toBe(requestId); + } finally { if (root && fixtureCleanupProven(messages, requestId, closed, errored)) liveFixtureRoots.delete(root); + if (!closed) worker?.unref(); } }, WINDOWS_APPLY_BUDGET_MS); diff --git a/tests/codex-integration/codex-cli-update-windows-review.test.ts b/tests/codex-integration/codex-cli-update-windows-review.test.ts new file mode 100644 index 00000000000..a0e06156c97 --- /dev/null +++ b/tests/codex-integration/codex-cli-update-windows-review.test.ts @@ -0,0 +1,244 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { ptr, toArrayBuffer } from "bun:ffi"; +import * as fs from "node:fs"; +import { join, win32 } from "node:path"; +import * as acl from "../../src/lib/windows-owner-acl"; +import { hardenSecretDirAsync } from "../../src/lib/windows-secret-acl"; +import { openWindowsCliApplyAuthority, type WindowsApplyAcquisition, type WindowsApplyAuthorityPins } from "../../src/codex/cli-update-job-store-windows"; +import { observeWindowsInstall, sameWindowsInstall, sameWindowsPreservedEntries, windowsInstallNamespaceSupported } from "../../src/codex/cli-update-windows-observation"; +import { actualApplyRetentionRootWithinTemp, verifyActualApplyRetentionRoot } from "../helpers/windows-cli-apply-retention-root"; +import { createCodexCliTestRoot } from "../helpers/codex-cli-fixture-root"; +import { trustWindowsTempFixtureAncestry } from "../helpers/windows-temp-ancestry"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Portable filesystem model; native Windows acceptance is kept separate below. +async function withDirectoryIdentity(run: (state: { ino: bigint; duringAcl?: () => void }) => Promise) { + const prefix = "C:\\ocx-observation-fixture", path = win32.join(prefix, "unrelated"); + const state = { ino: 9007199254740992n, duringAcl: undefined as (() => void) | undefined }; + const realStat = fs.lstatSync, realRead = fs.readdirSync; + const stat = spyOn(fs, "lstatSync").mockImplementation(((name: fs.PathLike, options?: { bigint?: boolean }) => { + if (name !== path) return realStat(name, options as { bigint: true }); + // Match lstat's number rounding unless the caller requests exact stats. + const scalar = (value: bigint) => options?.bigint ? value : Number(value); + return { dev: scalar(9007199254740995n), ino: scalar(state.ino), size: scalar(0n), nlink: scalar(1n), + isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false } as fs.BigIntStats; + }) as typeof fs.lstatSync); + const read = spyOn(fs, "readdirSync").mockImplementation(((name: fs.PathLike, ...args: unknown[]) => { + if (name === prefix) return ["unrelated"]; + if (name === path) return []; + return Reflect.apply(realRead, fs, [name, ...args]); + }) as typeof fs.readdirSync); + acl.setWindowsOwnerAclAsyncRunnerForTests(async entries => { + expect(entries).toEqual([{ path: win32.toNamespacedPath(path), directory: true, policy: "write-private" }]); + await Promise.resolve(); state.duringAcl?.(); + const user = "S-1-5-21-9-9-9-1001"; + return { success: true, timedOut: false, + stdout: `U|${user}|${user}|False\nE|0|${user}|4|1\nA|0|0|0|2032127|${user}|False\nEND` }; + }); + try { await run(state); } finally { acl.setWindowsOwnerAclAsyncRunnerForTests(null); read.mockRestore(); stat.mockRestore(); } +} + +test("directory snapshots retain adjacent identities beyond number precision", async () => { + await withDirectoryIdentity(async state => { + const first = await observeWindowsInstall("C:\\ocx-observation-fixture"); + state.ino++; + const second = await observeWindowsInstall("C:\\ocx-observation-fixture"); + expect(first.directories).toEqual(["unrelated:9007199254740995:9007199254740992"]); + expect(second.directories).toEqual(["unrelated:9007199254740995:9007199254740993"]); + expect(sameWindowsInstall(first, second)).toBe(false); + expect(sameWindowsPreservedEntries(first, second)).toBe(false); + expect(windowsInstallNamespaceSupported("C:\\ocx-observation-fixture")).toBe(true); + }); +}); + +test("an adjacent large directory identity changing during ACL read refuses observation", async () => { + await withDirectoryIdentity(async state => { + state.duringAcl = () => { state.ino++; }; + await expect(observeWindowsInstall("C:\\ocx-observation-fixture")).rejects.toThrow("installation_changed"); + }); +}); + +test("retention fixture accepts a normal nested temp child and rejects unsafe root spellings", () => { + const temp = "C:\\Users\\fixture\\AppData\\Local\\Temp"; + const root = temp + "\\session\\ocx-actual-apply-aB1234"; + expect(actualApplyRetentionRootWithinTemp(root, temp)).toBe(true); + expect(actualApplyRetentionRootWithinTemp(temp + "\\ocx-actual-apply-aB1234", temp)).toBe(true); + for (const candidate of [null, 42, temp, "C:\\ocx-actual-apply-aB1234", + temp + "-other\\ocx-actual-apply-aB1234", "D:\\ocx-actual-apply-aB1234", + "\\\\server\\share\\ocx-actual-apply-aB1234", root + ":stream", root + ".", root + "\\", + temp + "\\session\\..\\ocx-actual-apply-aB1234", temp + "\\unowned-aB1234", root.replaceAll("\\", "/")]) { + expect(actualApplyRetentionRootWithinTemp(candidate, temp), String(candidate)).toBe(false); + } +}); + +const native = process.platform === "win32" && process.arch === "x64" ? test : test.skip; +const roots = new Map(); +// Acquisitions whose native close failed stay strongly reachable for diagnosis; they are +// never revisited by a later afterEach. +const failedCleanup: { root: string; owners: WindowsApplyAcquisition[] }[] = []; +afterEach(() => { + acl.setWindowsOwnerAclAsyncRunnerForTests(null); + const failedRoots: string[] = []; + for (const [root, owners] of roots) { + roots.delete(root); + // No installer is created here. Evaluate every owner; a failed native close + // withholds root deletion and is reported once below. + const failed = owners.filter(owner => !owner.releaseIfClean()); + if (failed.length === 0) removeTreeWithRetry(root); + else { failedCleanup.push({ root, owners: failed }); failedRoots.push(root); } + } + expect(failedRoots, "fixture pins must close before root removal").toEqual([]); +}); +async function authorityFixture() { + const root = createCodexCliTestRoot("ocx-release-review-"), owners: WindowsApplyAcquisition[] = []; + roots.set(root, owners); + await hardenSecretDirAsync(root, { required: true }); trustWindowsTempFixtureAncestry(root); + const prefix = join(root, "prefix"); fs.mkdirSync(join(prefix, "node_modules", "@openai"), { recursive: true }); + const acquire = () => openWindowsCliApplyAuthority(prefix, true, owner => { owners.push(owner); }); + return { root, prefix, owners, acquire }; +} + +native("ACL-only release failure deletes only its owned lock and permits a later lease acquisition", async () => { + const f = await authorityFixture(), guard = await f.acquire(); + acl.setWindowsOwnerAclAsyncRunnerForTests(async () => ({ success: false, timedOut: true, stdout: "" })); + try { expect(await guard.release()).toBe(false); } + finally { acl.setWindowsOwnerAclAsyncRunnerForTests(null); } + expect(guard.resourcesReleased).toBe(true); + expect(f.owners[0]!.releaseIfClean()).toBe(true); + expect(fs.existsSync(guard.lock)).toBe(false); + const next = await f.acquire(); + try { + expect(await guard.release()).toBe(false); // Cannot release a newer owner. + expect(fs.existsSync(join(next.lock, "owner.json"))).toBe(true); + } finally { expect(await next.release()).toBe(true); } +}, 60000); + +native("original held deletion proves disposal even when the lock pathname cannot be observed", async () => { + const f = await authorityFixture(), guard = await f.acquire(); + const realStat = fs.lstatSync; + const stat = spyOn(fs, "lstatSync").mockImplementation(((path: fs.PathLike, ...args: unknown[]) => { + if (path === guard.lock) throw Object.assign(new Error("fixture namespace unreadable"), { code: "EACCES" }); + return Reflect.apply(realStat, fs, [path, ...args]); + }) as typeof fs.lstatSync); + acl.setWindowsOwnerAclAsyncRunnerForTests(async () => ({ success: false, timedOut: false, stdout: "" })); + try { + expect(await guard.release()).toBe(false); + expect(guard.resourcesReleased).toBe(true); + } finally { stat.mockRestore(); acl.setWindowsOwnerAclAsyncRunnerForTests(null); } + // This fixture created no child/staging, and restores the observation before teardown. + expect(f.owners[0]!.releaseIfClean()).toBe(true); + expect(fs.existsSync(guard.lock)).toBe(false); +}, 60000); + +native("a replacement lock after original handle close cannot retain the old authority", async () => { + const f = await authorityFixture(), guard = await f.acquire(); + const ctx = f.owners[0]!.resources as { kernel: Parameters[1] }; + const close = ctx.kernel.symbols.CloseHandle; + let replaced = false; + ctx.kernel.symbols.CloseHandle = handle => { + const buffer = Buffer.alloc(32768 * 2); + const length = ctx.kernel.symbols.GetFinalPathNameByHandleW(handle, ptr(buffer), 32768, 0); + const isLock = buffer.toString("utf16le", 0, length * 2) === win32.toNamespacedPath(guard.lock); + const result = close(handle); + if (isLock && result) { + fs.mkdirSync(guard.lock); + fs.writeFileSync(join(guard.lock, "owner.json"), "replacement-owner"); + replaced = true; + } + return result; + }; + try { expect(await guard.release()).toBe(true); } + finally { ctx.kernel.symbols.CloseHandle = close; } + expect(replaced).toBe(true); + expect(guard.resourcesReleased).toBe(true); + expect(fs.readFileSync(join(guard.lock, "owner.json"), "utf8")).toBe("replacement-owner"); + expect(await guard.release()).toBe(false); + expect(fs.readFileSync(join(guard.lock, "owner.json"), "utf8")).toBe("replacement-owner"); +}, 60000); + +native.each([1, 2])("failed deletion of held object %s never proves disposal", async refused => { + const f = await authorityFixture(), guard = await f.acquire(); + const ctx = f.owners[0]!.resources as { kernel: Parameters[1] }; + const dispose = ctx.kernel.symbols.SetFileInformationByHandle; + let deletes = 0; + ctx.kernel.symbols.SetFileInformationByHandle = (...args) => { + if (args[1] === 4 && ++deletes === refused) return 0; + return dispose(...args); + }; + try { + expect(await guard.release()).toBe(false); + expect(guard.resourcesReleased).toBe(false); + } finally { ctx.kernel.symbols.SetFileInformationByHandle = dispose; } + expect(f.owners[0]!.releaseIfClean()).toBe(true); + expect(fs.existsSync(guard.lock)).toBe(true); + await expect(f.acquire()).rejects.toThrow("job_store_busy"); +}, 60000); + +native("a failed native close retains its exact pin and execution token", async () => { + const f = await authorityFixture(), guard = await f.acquire(); + const ctx = f.owners[0]!.resources as { kernel: Parameters[1]; pins: WindowsApplyAuthorityPins }; + const close = ctx.kernel.symbols.CloseHandle; + let failed: bigint | undefined, releases = 0, attempts = 0; + ctx.kernel.symbols.CloseHandle = handle => { + failed ??= handle; + if (handle === failed) { attempts++; return 0; } + return close(handle); + }; + try { + expect(await guard.release()).toBe(false); + expect(guard.resourcesReleased).toBe(false); + expect(attempts).toBe(1); + expect(f.owners[0]!.releaseIfClean({ release() { releases++; } })).toBe(false); + expect(releases).toBe(0); + } finally { + ctx.kernel.symbols.CloseHandle = close; + // Restore the injected fault and explicitly dispose only this fixture's pin. + if (failed !== undefined) expect(ctx.pins.release(failed, ctx.kernel)).toBe(true); + } + expect(f.owners[0]!.releaseIfClean()).toBe(true); +}, 60000); + +native("remaining staging prevents disposal after lock deletion is refused", async () => { + const f = await authorityFixture(), guard = await f.acquire(); + const staging = await guard.createStaging(); + expect(await guard.release()).toBe(false); + expect(guard.resourcesReleased).toBe(false); + expect(fs.existsSync(staging)).toBe(true); + expect(f.owners[0]!.releaseIfClean()).toBe(true); + await expect(f.acquire()).rejects.toThrow("job_store_busy"); +}, 60000); + +for (const when of ["before ACL", "during ACL"] as const) native(`identity failure ${when} preserves lock and closes pins`, async () => { + const f = await authorityFixture(), guard = await f.acquire(); + const ctx = f.owners[0]!.resources as { kernel: Parameters[1] }; + const inspect = ctx.kernel.symbols.GetFileInformationByHandleEx; + const changedIdentity: typeof inspect = (...args) => { + const result = inspect(...args); + if (result && args[1] === 18) new Uint8Array(toArrayBuffer(args[2]!, 0, 24))[8]! ^= 1; + return result; + }; + if (when === "before ACL") ctx.kernel.symbols.GetFileInformationByHandleEx = changedIdentity; + else acl.setWindowsOwnerAclAsyncRunnerForTests(async () => { + ctx.kernel.symbols.GetFileInformationByHandleEx = changedIdentity; + return { success: false, timedOut: false, stdout: "" }; + }); + try { await expect(guard.release()).rejects.toThrow("unsafe_store"); } + finally { ctx.kernel.symbols.GetFileInformationByHandleEx = inspect; acl.setWindowsOwnerAclAsyncRunnerForTests(null); } + expect(guard.resourcesReleased).toBe(false); + expect(f.owners[0]!.releaseIfClean()).toBe(true); + expect(fs.existsSync(join(guard.lock, "owner.json"))).toBe(true); + expect(await guard.release()).toBe(false); + await expect(f.acquire()).rejects.toThrow("job_store_busy"); +}, 60000); + +native("retention root read-only admission requires canonical private OS-temp ancestry", async () => { + const parent = createCodexCliTestRoot("ocx-retention-parent-"); roots.set(parent, []); + await hardenSecretDirAsync(parent, { required: true }); + const root = createCodexCliTestRoot("ocx-actual-apply-", parent); + await hardenSecretDirAsync(root, { required: true }); + expect(await verifyActualApplyRetentionRoot(root)).toBe(root); + const alias = join(parent, "ocx-actual-apply-alias"); fs.symlinkSync(root, alias, "junction"); + await expect(verifyActualApplyRetentionRoot(alias)).rejects.toThrow("PRIVATE_TEMP_FIXTURE_REQUIRED"); + acl.setWindowsOwnerAclAsyncRunnerForTests(async () => ({ success: false, timedOut: false, stdout: "" })); + await expect(verifyActualApplyRetentionRoot(root)).rejects.toThrow("PRIVATE_TEMP_FIXTURE_REQUIRED"); +}, 60000); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6946a347fa8..3ea831f5ffe 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -46,6 +46,7 @@ "service-proxy-env.test.ts": "service", "codex-cli-update-windows-apply.test.ts": "codex-integration", "codex-cli-update-windows-apply-install.test.ts": "codex-integration", "codex-cli-update-windows-apply-outcomes.test.ts": "codex-integration", "codex-cli-update-windows-apply-shared.test.ts": "codex-integration", "codex-cli-update-windows-recovery.test.ts": "codex-integration", + "codex-cli-update-windows-review.test.ts": "codex-integration", "codex-cli-update-windows-pending.test.ts": "codex-integration", "codex-cli-update-windows-layout.test.ts": "codex-integration", "codex-quota-persistence.test.ts": "codex-integration", diff --git a/tests/helpers/codex-cli-windows-apply-fixture.ts b/tests/helpers/codex-cli-windows-apply-fixture.ts index 7100392e886..e453d571cfe 100644 --- a/tests/helpers/codex-cli-windows-apply-fixture.ts +++ b/tests/helpers/codex-cli-windows-apply-fixture.ts @@ -16,7 +16,8 @@ export const native = process.platform === "win32" && process.arch === "x64" ? t export const WINDOWS_APPLY_BUDGET_MS = 4 * SPAWN_BUDGET_MS; export const WINDOWS_APPLY_CHILD_BUDGET_MS = 2 * SPAWN_BUDGET_MS; export type WorkerFixtureReply = { type: string; requestId?: string; cleanupConfirmed?: boolean; result?: import("../../src/codex/cli-update-apply").CodexCliApplyResult }; -export function fixtureCleanupProven(messages: readonly unknown[], requestId: string, closed: boolean, errored: boolean): boolean { +export function fixtureCleanupProven(messages: readonly unknown[], requestId: string, closed: boolean, errored: boolean, + expectedErrors: readonly string[] = []): boolean { if (!closed || errored) return false; const replies: WorkerFixtureReply[] = []; for (const value of messages) { @@ -42,12 +43,13 @@ export function fixtureCleanupProven(messages: readonly unknown[], requestId: st const results = replies.filter(message => message.type === "result"); return results.length === 1 && results[0]!.cleanupConfirmed === true && results[0]!.result?.leaseRetained === false - && Array.isArray(results[0]!.result?.cleanupErrors) && results[0]!.result!.cleanupErrors.length === 0; + && Array.isArray(results[0]!.result?.cleanupErrors) + && JSON.stringify(results[0]!.result!.cleanupErrors) === JSON.stringify(expectedErrors); } export const mainRoot = "node_modules/@openai/codex", backingRoot = "node_modules/@openai/codex-win32-x64"; export const nativePath = "vendor/x86_64-pc-windows-msvc/bin/codex.exe"; export const write = (path: string, bytes: string | Buffer) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, bytes); }; -function artifacts(version: string, executable?: Buffer) { +export function artifacts(version: string, executable?: Buffer) { const pe = Buffer.alloc(512); pe.write("MZ"); pe.writeUInt32LE(64, 60); pe.write("PE\0\0", 64); pe.writeUInt16LE(0x8664, 68); pe.writeUInt16LE(1, 70); pe.writeUInt16LE(240, 84); pe.writeUInt16LE(2, 86); pe.writeUInt16LE(0x20b, 88); // Synthetic PE only. The real Node verification child executes this fixture's JS; diff --git a/tests/helpers/windows-apply-worker.ts b/tests/helpers/windows-apply-worker.ts index 5ed6e1f33fc..10fc6e6f234 100644 --- a/tests/helpers/windows-apply-worker.ts +++ b/tests/helpers/windows-apply-worker.ts @@ -3,6 +3,12 @@ import { applyCodexCliUpdate } from "../../src/codex/cli-update-apply"; import { createCodexCliWorkerLifetime } from "../../src/codex/cli-update-worker"; import { allocateCodexCliUpdateGateOwner, createWorkerCommitWindow, codexCliUpdateIsCancelled } from "../../src/codex/cli-update-selection-gate"; import { startWindowsCliChild } from "../../src/codex/windows-cli-child"; +import { setWindowsOwnerAclAsyncRunnerForTests } from "../../src/lib/windows-owner-acl"; +import type { openWindowsCliApplyAuthority } from "../../src/codex/cli-update-job-store-windows"; +import type { WindowsApplyAcquisition, WindowsApplyAuthorityPins } from "../../src/codex/cli-update-job-store-windows"; +import { ptr } from "bun:ffi"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { win32 } from "node:path"; declare const self: Worker; if (Bun.isMainThread === false) { @@ -11,7 +17,9 @@ if (Bun.isMainThread === false) { self.onmessage = async event => { if (ran) return; ran = true; - const { requestId, planId, inspectionDeps, target, gate, fence } = event.data; + const { requestId, planId, inspectionDeps, target, gate, fence, aclReleaseFailure, replaceLock } = event.data; + let anchor: { acquisition?: WindowsApplyAcquisition; owner?: { authority: Awaited> } } | undefined; + let replacementCreated = false; const nativeSettlements: { terminal: boolean; cleanupComplete: boolean }[] = []; const abort = new AbortController(); const view = new Int32Array(gate), word = new Int32Array(fence); @@ -21,8 +29,8 @@ if (Bun.isMainThread === false) { platform: "win32", inspectionDeps, signal: abort.signal, isCancelled: codexCliUpdateIsCancelled(fence), commitWindow: createWorkerCommitWindow({ gate, fence, owner }), - retainExecution: owner => lifetime.retain(owner), - resolveTarget: () => ({ kind: "resolved", version: "1.1.0", + retainExecution: owner => { anchor = owner; return lifetime.retain(owner); }, + resolveTarget: () => ({ kind: "resolved", version: target.set.main.version, integrity: target.set.main.integrity, artifacts: target.set }), // The installer and metadata are offline owned fixtures; no host session controls. scanProcesses: () => ({ kind: "observed", processes: [] }), @@ -32,6 +40,28 @@ if (Bun.isMainThread === false) { }, startWindowsChild: invocation => { const installing = invocation.policy === "installer"; + if (installing && (aclReleaseFailure || replaceLock)) { + const authority = anchor!.owner!.authority, release = authority.release.bind(authority); + authority.release = async () => { + const ctx = anchor!.acquisition!.resources as { kernel: Parameters[1] }; + const close = ctx.kernel.symbols.CloseHandle; + if (replaceLock) ctx.kernel.symbols.CloseHandle = handle => { + const buffer = Buffer.alloc(32768 * 2); + const length = ctx.kernel.symbols.GetFinalPathNameByHandleW(handle, ptr(buffer), 32768, 0); + const isLock = buffer.toString("utf16le", 0, length * 2) === win32.toNamespacedPath(authority.lock); + const result = close(handle); + if (isLock && result) { + mkdirSync(authority.lock); + writeFileSync(win32.join(authority.lock, "owner.json"), requestId); + replacementCreated = true; + } + return result; + }; + if (aclReleaseFailure) setWindowsOwnerAclAsyncRunnerForTests(async () => ({ success: false, timedOut: false, stdout: "" })); + try { return await release(); } + finally { ctx.kernel.symbols.CloseHandle = close; setWindowsOwnerAclAsyncRunnerForTests(null); } + }; + } if (installing && (Atomics.load(view, 0) !== owner || Atomics.load(word, 0) !== 2)) throw new Error("native start outside commit"); const child = startWindowsCliChild(invocation), wait = child.wait.bind(child); child.wait = async () => { @@ -44,7 +74,7 @@ if (Bun.isMainThread === false) { return child; }, }); - self.postMessage({ type: "result", requestId, result, cleanupConfirmed: nativeSettlements.length > 0 + self.postMessage({ type: "result", requestId, result, cleanupConfirmed: (!replaceLock || replacementCreated) && nativeSettlements.length > 0 && nativeSettlements.every(item => item.terminal && item.cleanupComplete) }); } catch { self.postMessage({ type: "fixture-error", requestId }); diff --git a/tests/helpers/windows-cli-apply-retention-root.ts b/tests/helpers/windows-cli-apply-retention-root.ts new file mode 100644 index 00000000000..ae3191c7879 --- /dev/null +++ b/tests/helpers/windows-cli-apply-retention-root.ts @@ -0,0 +1,32 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { win32 } from "node:path"; +import { verifyWindowsPrivateEntriesAsync } from "../../src/lib/windows-owner-acl"; + +/** The session creates a canonical private child anywhere below OS temp, not + * directly below a drive root. Keep the lexical contract portable for tests. + */ +export function actualApplyRetentionRootWithinTemp(root: unknown, temporaryRoot: string): root is string { + if (typeof root !== "string" || !/^[A-Za-z]:\\/.test(root) || !/^[A-Za-z]:\\/.test(temporaryRoot) || root.endsWith("\\") + || win32.normalize(root) !== root || win32.normalize(temporaryRoot) !== temporaryRoot + || !/^ocx-actual-apply-[A-Za-z0-9_-]+$/.test(win32.basename(root))) return false; + const within = win32.relative(temporaryRoot, root); + return !!within && within !== ".." && !within.startsWith("..\\") && !win32.isAbsolute(within); +} + +const dosPath = (path: string) => path.startsWith("\\\\?\\") ? path.slice(4) : path; + +/** Read-only admission: no alias, reparse leaf, outside-temp path or broad root + * ACL is repaired into eligibility. The session must harden its own root first. + */ +export async function verifyActualApplyRetentionRoot(root: unknown): Promise { + const temporaryRoot = dosPath(realpathSync.native(tmpdir())); + if (!actualApplyRetentionRootWithinTemp(root, temporaryRoot) + || dosPath(realpathSync.native(root)) !== root) throw new Error("PRIVATE_TEMP_FIXTURE_REQUIRED"); + const stat = lstatSync(root); + if (!stat.isDirectory() || stat.isSymbolicLink() + || !await verifyWindowsPrivateEntriesAsync([{ path: win32.toNamespacedPath(root), directory: true }], 5000)) { + throw new Error("PRIVATE_TEMP_FIXTURE_REQUIRED"); + } + return root; +} diff --git a/tests/helpers/windows-cli-apply-retention-worker.ts b/tests/helpers/windows-cli-apply-retention-worker.ts index 9948f0169e9..3953be75f44 100644 --- a/tests/helpers/windows-cli-apply-retention-worker.ts +++ b/tests/helpers/windows-cli-apply-retention-worker.ts @@ -8,6 +8,7 @@ import { createCodexCliUpdatePlan } from "../../src/codex/cli-update-plan"; import { createCodexCliWorkerLifetime, type CodexCliUpdateWorkerRunMessage } from "../../src/codex/cli-update-worker"; import { createWorkerCommitWindow, codexCliUpdateIsCancelled } from "../../src/codex/cli-update-selection-gate"; import { openWindowsCliApplyAuthority } from "../../src/codex/cli-update-job-store-windows"; +import { verifyActualApplyRetentionRoot } from "./windows-cli-apply-retention-root"; import { holdWindowsCodexCliPlanToolchain } from "../../src/codex/cli-windows-plan-provenance"; import { WindowsChildNative, WIN64_CHILD_ABI } from "../../src/codex/windows-cli-child-native"; import { startWindowsCliChildWithNative, type WindowsCliChild, type WindowsCliChildResult } from "../../src/codex/windows-cli-child"; @@ -255,10 +256,12 @@ if (!Bun.isMainThread) self.onmessage = async event => { if (!inspecting) { need(!busy, "BUSY_OWNER_HOLD"); if (!manual) automaticAllowed(); busy = true; acquired = true; } if (m?.type === "fixture-init") { need(!fixture && !telemetryNonce && m.executionAuthorized === true && process.platform === "win32" && process.arch === "x64" - && typeof m.nonce === "string" && m.nonce.length === 36 && /^[A-Z]:[\\/]ocx-actual-apply-[^\\/]+$/i.test(m.root), "EXPLICIT_FIXTURE_INIT_REQUIRED"); + && typeof m.nonce === "string" && m.nonce.length === 36, "EXPLICIT_FIXTURE_INIT_REQUIRED"); need(m.stopFlag instanceof SharedArrayBuffer && m.stopFlag.byteLength === 4, "PRIVATE_FAILURE_FENCE_REQUIRED"); telemetryNonce = m.nonce; stopFlag = new Int32Array(m.stopFlag); - fixture = await prepareRetentionFixture(m.root); + const root = await verifyActualApplyRetentionRoot(m.root); + automaticAllowed(); + fixture = await prepareRetentionFixture(root); automaticAllowed(); const plan = await createCodexCliUpdatePlan(fixture.deps); automaticAllowed();