diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 12462a358..3bfc6d298 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -136,6 +136,7 @@ jobs: cmake_extra_args: -DACECODE_BUILD_DESKTOP=ON - id: macos-x64 os: macos-15 + installer_arch: x64 triplet: x64-osx executable: acecode archive_extension: tar.gz @@ -143,11 +144,12 @@ jobs: cmake_extra_args: -DCMAKE_OSX_ARCHITECTURES=x86_64 -DACECODE_BUILD_DESKTOP=ON - id: macos-arm64 os: macos-15 + installer_arch: arm64 triplet: arm64-osx executable: acecode archive_extension: tar.gz build_desktop: true - cmake_extra_args: -DACECODE_BUILD_DESKTOP=ON + cmake_extra_args: -DCMAKE_OSX_ARCHITECTURES=arm64 -DACECODE_BUILD_DESKTOP=ON steps: - name: Checkout @@ -508,6 +510,31 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} run: scripts/macos_notarize_app.sh --app "build/ACECode.app" + - name: Test macOS installer integration + if: runner.os == 'macOS' + shell: bash + run: | + bash tests/scripts/macos_installer_test.sh + python3 tests/scripts/macos_installer_release_test.py + + - name: Build signed and notarized macOS installer DMG + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + id: macos-dmg + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + dmg_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}.dmg" + bash scripts/macos_create_installer_dmg.sh \ + --app "build/ACECode.app" \ + --output "$dmg_path" \ + --arch "${{ matrix.installer_arch }}" \ + --identity "${{ steps.macos-keychain.outputs.identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" + echo "path=$dmg_path" >> "$GITHUB_OUTPUT" + - name: Create macOS self-update archive if: runner.os == 'macOS' id: macos-update @@ -781,6 +808,14 @@ jobs: path: ${{ steps.linux-update.outputs.path }} if-no-files-found: error + - name: Upload macOS installer DMG + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-dmg + path: ${{ steps.macos-dmg.outputs.path }} + if-no-files-found: error + - name: Upload macOS PKG if: runner.os == 'macOS' && steps.macos-release.outputs.pkg_enabled == 'true' uses: actions/upload-artifact@v4 @@ -1156,6 +1191,9 @@ jobs: - build-linux-old-package runs-on: ubuntu-latest steps: + - name: Checkout release asset selector + uses: actions/checkout@v4 + - name: Download all artifacts uses: actions/download-artifact@v4 with: @@ -1167,11 +1205,22 @@ jobs: run: | mkdir -p release-assets - unexpected_dmg="$(find artifacts -type f -name '*.dmg' -print -quit)" - if [ -n "$unexpected_dmg" ]; then - echo "::error::DMG artifacts are no longer permitted in tagged releases: $unexpected_dmg" + release_version="${GITHUB_REF_NAME#v}" + # Only exact update ZIPs may be selected for updater publication. + python3 scripts/select_macos_update_assets.py artifacts "$release_version" + dmg_count="$(find artifacts -type f -iname '*.dmg' | wc -l | tr -d '[:space:]')" + if [ "$dmg_count" -ne 2 ]; then + echo "::error::Tagged releases require exactly two signed macOS installer DMGs; found $dmg_count" exit 1 fi + for arch in x64 arm64; do + expected_dmg="ACECode-${release_version}-macos-${arch}.dmg" + matching_count="$(find artifacts -type f -name "$expected_dmg" | wc -l | tr -d '[:space:]')" + if [ "$matching_count" -ne 1 ]; then + echo "::error::Expected exactly one $expected_dmg; found $matching_count" + exit 1 + fi + done unsigned_pkg="$(find artifacts -type f -name '*-unsigned.pkg' -print -quit)" if [ -n "$unsigned_pkg" ]; then @@ -1204,7 +1253,7 @@ jobs: echo "::notice::No macOS PKGs were produced; publishing the remaining trusted assets" fi - find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.pkg' \) -print0 | + find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.pkg' -o -name '*.dmg' \) -print0 | while IFS= read -r -d '' file; do cp "$file" "release-assets/$(basename "$file")" done diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b0fbc092..3190e7d31 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,23 @@ on: - master jobs: + macos-installer-tests: + name: macos-installer-tests + runs-on: macos-15 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Test native installer and both UI architectures + run: bash tests/scripts/macos_installer_test.sh + + - name: Test offline macOS release scripts + run: | + python3 tests/scripts/macos_installer_release_test.py + python3 tests/scripts/macos_portable_package_test.py + bash tests/scripts/macos_release_scripts_test.sh + web-tests: name: web-tests (linux-x64) runs-on: ubuntu-22.04 diff --git a/CLAUDE.md b/CLAUDE.md index c753d81fd..6634dcf91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -476,6 +476,8 @@ web/ **实时 transcript 状态是单写者,渲染层只能订阅。** `web/src/lib/singleWriterStore.js` 的 `commit(producer)` 是唯一写入口,producer 入参恒为最新已提交状态;`useSessionTranscript` 经 `useSyncExternalStore` 订阅,hook 内没有 `useState`/`setState`。曾经的写法是 `useState` 镜像 + `useEffect(() => { stateRef.current = state; }, [state])`:被动 effect 在提交后异步刷新,一旦它带着旧渲染快照回写,其后到达的 token 就从旧快照继续拼接,中间已收到的片段被永久覆盖 —— 表现为长会话喷字时正文中间随机缺汉字/标点/换行(Markdown 把它放大成"乱码"),回合结束后被完整 message 与 self-heal 覆盖回正确结果。这条路径在传输层完全隐形:`seq` 仍单调增长、去重不报警、服务端 replay 完整。因此 `commit` 刻意只收 producer(值形式的回写在入口就被拒),`subscribe` 的通知不带状态(订阅者手里不会留下可回写的旧快照),历史加载/追赶重放/定时刷新/self-heal 覆写全部收敛成单次原子提交。**同一个 store 也承载 ChatView 的排队输入状态**(`queueStateRef` 曾是同款双写,可见后果是排队消息被吞或已取消的重新出现);`drainQueuedInput` 的取件与标记 sending、以及用户消息落库后的整轮完成标记,都收敛成单次提交。回归:`singleWriterStore.test.js` + `transcriptStreamIntegrity.test.js`(高速重放的流式前缀不变量 + 旧双写模型的失效见证)+ `chatInputQueueIntegrity.test.js` + `singleWriterOwnershipArchitecture.test.js`。详见 [docs/web-chat/transcript-state.md](docs/web-chat/transcript-state.md)。 +**事件回放去重要以 daemon 的真实事件序为准(fix: 回答问题后上一轮 AI 正文重复显示)。** 切进正在运行的会话时,REST `GET messages?since=0` 先给出已落盘历史,再因 busy 用 `since=1` 把整个事件环(1024 条,含上一轮)回放进同一份 transcript;WS 用 sessionStorage 里的旧游标订阅时也会逐条回放。回放依赖去重:同一条 assistant 的 token 流与 message 事件必须整体落到 REST 已有的那条上。daemon 的顺序是 `token… → usage → message(assistant) → tool_start`(`Usage` 在 `chat_stream` 一返回就 emit,assistant `Message` 要到 `execute_tool_calls` / 文本回合收尾才发;工具回合的 tool-call 增量还会夹 `agent_progress(tool_planning)`)。`sessionTranscript.js` 的两个回放循环(`applyTranscriptReplayEvents` / `loadTranscriptHistory`)曾经「遇到任何非流式事件就 flush 攒着的 token」,于是 usage 把 token 提前流成新草稿,紧接着按 id 判重的 message 再把草稿定稿成第二份 —— 用户看到的就是「AI 上次的回答在我的消息后面又出现一次,切走再切回才消失」(实测用 [U1, A1, U2] + 环回放能稳定复现)。现在:(1) 只有会追加 / 收尾 transcript 条目的事件才 flush,`STREAM_NEUTRAL_EVENT_TYPES`(usage / agent_progress / model_step_* / goal / todo / permission / question)一律不 flush;(2) 推迟应用的 token 经 `reduceDeferredStreamEvent` 绕过被旁路事件推高的 seq 水位,否则被中断回合的正文会整段消失;(3) reducer 里 `replayed:true` 的 assistant message 命中已有条目时丢掉草稿、原位更新,`replayed:true` 的 `tool_start` 命中已落盘的同 call id 工具项时绑到它身上而不是再追加(答完的 AskUserQuestion 卡片否则会出现两张)。**去重只认 `replayed` 帧**:assistant 消息 id 是内容哈希(`sha1(role + content)`,与 JSONL 重读一致),实时回合里模型说一句与旧消息一字不差的话 id 也会撞上,那是真的新回复;有的 provider 还会跨回合复用 tool call id。回归测试在 `sessionTranscript.test.js`(「live catch-up replay:token 与已持久化 message 之间夹着 usage…」一组)。 + **Transcript 尾部窗口(渐进虚拟化)**:大会话切入时只渲染最近 `INITIAL_TAIL_ITEMS`(120)条投影行,更早的行折成顶部「显示更早的 N 条消息 / 显示全部」入口(`web/src/lib/transcriptWindow.js` 纯函数 + Node 测试)。窗口按**首个可见行的 user 行 id** 锚定(user 行不参与活动折叠,id 跨投影稳定;流式追加在尾部,锚不动、窗口自然增长;锚丢失 fail-open 全量)。渲染 map 走 `windowedItems`,但 `itemsRef`/scrubber 回合列表/turnFileListPlacement 仍吃全量 `renderedItems` —— scrubber 跳转与搜索深链(`searchJumpOrdinal`)目标不在 DOM 时先 `expandTranscriptWindow()` 再重试;Ctrl+F 查找是 DOM 文本搜索,`GlobalFindOverlay.openFind` 广播 `acecode:conversation-find-open` 事件让 ChatView 先全量展开。「显示更早」向上补条目需手动滚动补偿(`windowRevealScrollRef` 按 scrollHeight 差值回补,原生锚定已被 overflow-anchor:none 关掉)。窗口状态是 render-phase 调整(React 文档模式)—— 用 useEffect 会先全量渲染一帧,虚拟化就白做了;transcript 重置(条目清零)回到未初始化重定锚,因为 item id 会被重载复用。 `lib/usePreference.js` 是 UI 偏好读写的统一入口(`usePreference(key, defaults, validator?)` 返回 `[value, setValue]`,setter 接受 partial 浅合并 / 函数式更新,localStorage 写失败静默吞)。当前订阅:`ace.theme`(由 `theme.jsx::ThemeProvider`)、`acecode.singleLayoutWidths.v1`(由 `App.jsx`,`{sidebar, sidePanel}` 像素宽)、`acecode.uiPrefs.v1`(由 `App.jsx`,`{view: 'single'|'grid4'|'grid9', sidePanelCollapsed: boolean}` — view 模式与 SidePanel 折叠态跨刷新持久化)。同一 key 在整个 App 内只允许一个 hook 实例订阅,避免多 useState 之间互覆盖。 diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 1bdf7aff1..8521aad2d 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -931,10 +931,10 @@ storage directly; they do not call the daemon HTTP API: |---|---| | `create_thread` | create a background thread and queue its initial prompt | | `fork_thread` | fork completed persisted history into a new thread | -| `list_threads` | return pinned threads plus a bounded recent list | -| `read_thread` | read bounded, cursor-paginated turns | +| `list_threads` | globally list pinned threads plus cursor-paginated recent threads | +| `read_thread` | read bounded, cursor-paginated turns from any workspace | | `send_message_to_thread` | queue a follow-up prompt | -| `wait_threads` | wait for up to eight targets using event cursors | +| `wait_threads` | wait for up to eight targets across workspaces using event cursors | | `set_thread_title` | rename a thread | | `set_thread_pinned` | update the existing pinned-session state | | `set_thread_archived` | archive or unarchive a thread | @@ -942,6 +942,22 @@ storage directly; they do not call the daemon HTTP API: | `repair_thread` | append a deterministic repair checkpoint to another thread | | `create_workspace` | register an existing absolute directory as a visible workspace | +`list_threads`, `read_thread`, and `wait_threads` discover sessions across the +entire ACECode data directory, including hidden/unregistered workspaces and +workspace-free sessions. A caller cwd is not required. List rows include +`workspaceHash` (the storage project hash, also for workspace-free sessions), +`cwd`, `noWorkspace`, `workspaceName`, and `workspaceVisible` as metadata. +Child sessions retain `parentThreadId`. The list excludes archived sessions +unless `includeArchived:true`; explicit reads can access archived sessions. +All non-archived pins are returned in `pinnedThreads`, ordered by project hash +and then that project's persisted pin order. Only `threads` is subject to +`limit` (default 20, maximum 50). Pass `nextCursor` back as `cursor` to read +the next page; it is an offset into the current ordering, not a frozen snapshot. +`errors` reports incomplete project scans. `read_thread` and each `wait_threads` +target accept optional `workspaceHash` from the list for direct lookup and +disambiguation when the same `threadId` exists in multiple projects. +Creation and mutation tools retain their existing calling-workspace semantics. + `delete_thread` also removes search-index and pin records. It may target its calling thread, including a cascade whose tree contains the caller. In that case it records the canonical tool result and turn timing, emits the terminal @@ -1209,6 +1225,26 @@ Structured attachment references in the retained history are also copied and remapped, including their provider content parts and preview records; recalling those messages does not depend on the source session's attachment storage. +### `POST /api/sessions/:id/messages/retry` + +Retries the exact trailing user message in an idle live session. Body: + +```json +{"expected_user_message_id":"persisted-user-message-id"} +``` + +The request accepts only this field. The backend verifies the full visible +transcript and model history end with that user message, with no active or +queued work. It checks again when the worker starts. Hidden bookkeeping +records do not count as transcript messages; assistant (including empty +messages), tool, system and error messages prevent retry. + +The original message, attachments and context are reused without appending +another user record. Returns `202 {"queued":true,"user_message_id":"..."}`; +malformed requests return `400`, and unavailable sessions, stale message IDs +or active/queued work return `409`. This endpoint does not create or resume a +session, expand commands, or accept new input. + ### `POST /api/sessions/:id/messages` Queues a user input turn. Body: @@ -3683,16 +3719,18 @@ packages also verify the installed backend before reporting success; a failed post-copy verification rolls back the installation. Version mismatch, timeout, invalid output and unsuccessful probe exit fail the job with an actionable error. -On macOS, a daemon running from either the current-user -`~/Applications/ACECode.app/Contents/MacOS/acecode-daemon` location or the -supported system `/Applications/ACECode.app/Contents/MacOS/acecode-daemon` -location installs a complete +On macOS, a daemon running from `ACECode.app/Contents/MacOS/acecode-daemon` +installs a complete `ACECode.app` update ZIP rather than copying files into `Contents/MacOS`. Before -replacement, the daemon requires one of those exact non-symlinked install paths, -a writable containing directory, a strict nested Apple signature, bundle +replacement, the daemon requires an absolute, canonical, real `ACECode.app` +with a real, writable containing directory, a strict nested Apple signature, bundle identifier `dev.acecode.desktop`, the selected manifest version, and the same -Developer Team ID as the installed app. An app running from any other location -fails the job without mutating that bundle. +Developer Team ID and designated signing requirement as the installed app. +Custom folders are supported as well as `~/Applications` and `/Applications`; +App Translocation, symlinked paths, and apps nested in another `.app` are rejected. +Read-only or otherwise unwritable locations require moving the app to a writable +folder or installing manually; the updater does not elevate privileges. This does +not change the separate personal-install destination policy. ### `GET /api/update/job` @@ -3738,7 +3776,10 @@ Choosing restart later leaves the current process running. Normal browser and Edge-app compatibility clients do not own the desktop lifecycle, so they show manual full-exit-and-relaunch guidance instead of an automatic restart action. For a successful macOS bundle update, `backup_dir` identifies the retained -`.ACECode.previous.app` beside the running installation. +`.ACECode-.previous.app` beside the running installation. Existing backups +are not deleted or overwritten; retained backups require manual cleanup when no +longer needed. The updater lock is opened without following symlinks and must be +a regular, singly linked file owned by the current user. ### `GET /api/mcp` diff --git a/docs/macos-release.md b/docs/macos-release.md index 4e71a3afc..bf801459b 100644 --- a/docs/macos-release.md +++ b/docs/macos-release.md @@ -1,7 +1,8 @@ -# macOS PKG Release Guide +# macOS Installer And PKG Release Guide ACECode's `package` GitHub Actions workflow builds separate Intel -(`macos-x64`) and Apple silicon (`macos-arm64`) self-update ZIPs and archives. +(`macos-x64`) and Apple silicon (`macos-arm64`) installer DMGs, self-update ZIPs, +and archives. When the separate Developer ID Installer credentials are configured, it also builds PKG installers for both architectures. Tagged releases always fail instead of publishing an unsigned or unnotarized macOS application artifact; @@ -9,6 +10,18 @@ when only the Installer credentials are absent, they publish no PKG at all. ## What Users Install +Download `ACECode--macos-.dmg`, open it, and launch +**ACECode Installer.app**. The v6 native installer offers personal installation +(normally `~/Applications`, falling back to `~/Apps`), system `/Applications`, +and a custom writable folder. System/custom installation uses the current +account's actual permissions; it never requests elevation. Quit the destination +ACECode before replacement. Replacement requires confirmation, stages and +validates a copy, and attempts rollback on failure without deleting user data. +If rollback or old-file cleanup fails, the installer reports the retained path. +The graphical installer and in-app updater share `.ACECode.update.lock`, so +they cannot replace the same installation concurrently. Both reject unsafe lock +files without blocking or changing the installed application. + When the Release contains PKGs, download the one matching the Mac architecture, double-click it, and confirm the normal steps in Apple's Installer application. The package offers only the current-user installation domain, so Installer @@ -35,15 +48,14 @@ same user-level application component. Users can uninstall ACECode by quitting it and deleting `~/Applications/ACECode.app`; no privileged uninstaller is required. Installer receipts may remain in the user's receipt database. -ACECode no longer builds or publishes a DMG. There is no fake Applications -folder and no Finder drag-install path. A Release without PKGs still provides -signed and notarized macOS archives and trusted self-update ZIPs; existing -installations can update normally, while first-time graphical installation is -not represented as an unsigned substitute installer. +The DMG contains only the signed, notarized, stapled native installer. There is +no fake Applications folder or Finder drag-install target. PKGs remain an +optional alternative; absence of Developer ID Installer credentials does not +disable DMGs, which use the Developer ID Application identity. ## Self-Update Trust And Replacement -The graphical PKG is distinct from the self-update archive. The update service +The graphical DMG and PKG are distinct from the self-update archive. The update service continues using `ACECode--macos--update.zip`, `aceupdate.json`, package size, and SHA-256. Native preflight additionally enforces: @@ -52,21 +64,33 @@ package size, and SHA-256. Native preflight additionally enforces: - Strict Apple signatures for the app and nested code. - Bundle identifier `dev.acecode.desktop`, the manifest version, and the same non-empty Apple Developer Team ID as the installed copy. -- An exact, non-symlinked `~/Applications/ACECode.app` or legacy - `/Applications/ACECode.app` destination. +- An absolute, canonical, real `ACECode.app` destination with a writable parent, + outside App Translocation and other app bundles. Custom directories are + supported alongside `~/Applications` and `/Applications`. -The updater stages a hidden sibling, validates it, retains one previous bundle, +The updater stages a hidden sibling, validates it, retains a uniquely named previous bundle, and rolls back when replacement validation fails. New PKG installations always use `~/Applications`; existing `/Applications` installations remain supported but are neither relocated nor deleted automatically. +Custom installer locations (including the `~/Apps` fallback) update in place +without elevation when the current account has permission. Unwritable locations +require a manual installation or moving the app to a writable folder. Retained +`.ACECode-.previous.app` backups require manual cleanup when no longer +needed; the updater does not delete unrelated or existing backups. + +Older ACECode versions with the two-directory restriction need one manual +installation of the new version before custom-location self-update is available. +The new installer cannot change the updater policy inside an old binary. + ## One-Time Apple And GitHub Setup ### Export both Developer ID identities -The release needs two different identities with their private keys: +The full release can use two different identities with their private keys: -- `Developer ID Application` signs ACECode executables and `ACECode.app`. +- `Developer ID Application` signs ACECode executables, `ACECode.app`, the + hardened-runtime installer wrapper, and the DMG. - `Developer ID Installer` signs the outer `.pkg` product archive. Export each identity from Keychain Access as its own password-protected `.p12`. @@ -135,12 +159,13 @@ no effect while npm publication is disabled. - With all seven macOS secrets configured, the app and two architecture PKGs are signed, notarized, stapled, Gatekeeper-checked, and uploaded alongside - trusted update ZIPs. + trusted update ZIPs and two installer DMGs. - With complete Application/notarization secrets but neither Installer secret, the app is still signed, notarized, and stapled and the workflow uploads - trusted update ZIPs and archives, but creates no PKG artifact. + trusted update ZIPs, installer DMGs, and archives, but creates no PKG artifact. - Without complete Application/notarization credentials, a manual run may emit a clearly suffixed `-update-unsigned.zip` for structural inspection only. + No installer DMG is built or uploaded in this mode. - A `v*` tag never permits unsigned fallback, and a partial Installer pair is always rejected. @@ -149,6 +174,10 @@ non-admin account before tagging. Confirm that Installer does not show an administrator authentication sheet and that the result exists only at `~/Applications/ACECode.app`. When PKGs are intentionally omitted, confirm the dry run has no PKG artifact and that both trusted update ZIPs completed. +For each architecture, also test the downloaded, quarantined DMG on a clean +standard account: personal install, denied system location, writable custom +location, running-app refusal, replacement confirmation, launch, and reveal. +Test on macOS 11 as well as a current OS before claiming runtime compatibility. ## Tagged Release @@ -162,7 +191,10 @@ git push origin v0.8.16 ``` After all platform builds succeed, GitHub creates a Release containing matching -self-update ZIPs, platform archives, debug symbols, and `SHA256SUMS.txt`. When +self-update ZIPs, platform archives, debug symbols, and `SHA256SUMS.txt`. It must +contain exactly `ACECode--macos-x64.dmg` and +`ACECode--macos-arm64.dmg`; missing, extra, duplicate, misversioned, or +unsigned-suffixed DMGs fail release collection. When Installer credentials are available it also contains exactly the x64 and arm64 PKGs; otherwise it contains zero PKGs. Do not reuse a failed public tag; fix the cause, increment the version, and create a new tag. @@ -170,7 +202,7 @@ cause, increment the version, and create a new tag. ## Publish To The Update Service Upload only the final `*-update.zip` files beside `aceupdate.json`. Do not point -update records at the PKG, npm archive, or an unsigned dry-run ZIP. Calculate +update records at a DMG, PKG, npm archive, or an unsigned dry-run ZIP. Calculate the exact digest and size from uploaded files: ```bash @@ -182,6 +214,72 @@ Publish ZIPs before changing the manifest so clients never observe a release whose package is missing. Bundled-app updates also pin the Developer ID Team; standalone CLI updates retain the manifest, size, and checksum trust contract. +Use `python3 scripts/select_macos_update_assets.py release-assets ` to +select the exact pair of macOS updater ZIPs. Release collection uses this same +fail-closed selector; DMGs and PKGs cannot become updater assets through broad +architecture-name matching. The workflow does not publish `aceupdate.json` or +upload files to the update service automatically. + +## Native Installer Production Pipeline + +The repository owns the v6 sources in +[`packaging/macos-installer`](../packaging/macos-installer). The Swift UI, engine, +and standalone tests are maintained with the updater's shared lock contract; +the build script is adapted for production. No local release binaries or +prebuilt payloads are used. + +After the existing `build/ACECode.app` signing, notarization, and stapling step, +CI invokes [`macos_create_installer_dmg.sh`](../scripts/macos_create_installer_dmg.sh) +with the explicit matrix `x64` or `arm64` architecture, imported Application +certificate fingerprint, and temporary keychain. It: + +1. Verifies the payload's architecture, bundle ID, Developer ID signature, + pinned team `T52GZCH73Y`, stapled ticket, and Gatekeeper trust. +2. Compiles the wrapper with an explicit `x86_64-apple-macosx11.0` or + `arm64-apple-macosx11.0` target and `LSMinimumSystemVersion=11.0`. + Versions and icon come from the exact payload. This does not lower the + embedded ACECode application's own minimum OS requirement. +3. Copies the payload with `ditto`, signs only the wrapper with hardened runtime + and timestamp (never `codesign --deep` signing), and checks payload equality. +4. Submits the wrapper ZIP, requires `Accepted`, staples and validates the + installer, then creates a compressed DMG containing that installer only. +5. Signs the DMG with the same Application identity, notarizes/staples it, + validates its signature/ticket and Gatekeeper assessment, and publishes the + output only after all checks succeed. Rejected submissions fetch the notary + log when available. Existing output paths are refused, not overwritten. + +Both stages reuse `APPLE_ID`, `APPLE_TEAM_ID`, and +`APPLE_APP_SPECIFIC_PASSWORD`, or `--keychain-profile` / `NOTARYTOOL_PROFILE` +locally. No additional certificate is required. All DMG build/upload steps use +`macos-release.enabled`; only PKG steps use `pkg_enabled`. Signing-material +cleanup remains an `always()` step after all packaging operations. + +After preparing a trusted payload, an authorized release operator can run: + +```bash +bash scripts/macos_create_installer_dmg.sh \ + --app build/ACECode.app --arch arm64 \ + --output dist/ACECode-local-macos-arm64.dmg \ + --identity "$app_identity" --keychain-profile ACECode-notary +``` + +Offline verification (no signing identities or live notarization): + +```bash +bash tests/scripts/macos_installer_test.sh +python3 tests/scripts/macos_installer_release_test.py +bash tests/scripts/macos_release_scripts_test.sh +``` + +The PR test workflow runs these checks on macOS without signing credentials. +The first command runs engine fault/rollback and unsafe-lock tests with assertions +enabled and cross-compiles both UI architectures for macOS 11. It also launches +a test bundle and captures personal/system installation windows without +installing an application. The second uses mocked Apple tools to test sequencing, +architecture rejection, unchanged payloads, rejected +notarization, release DMG naming/count, and updater asset selection. These do +not replace live release signing, notarization, or clean-machine smoke tests. + ## Local Build And PKG Check Given an already configured macOS build directory: diff --git a/openspec/changes/replace-usage-trend-with-heatmap/design.md b/openspec/changes/replace-usage-trend-with-heatmap/design.md index d8a70b2cd..49345710f 100644 --- a/openspec/changes/replace-usage-trend-with-heatmap/design.md +++ b/openspec/changes/replace-usage-trend-with-heatmap/design.md @@ -17,6 +17,7 @@ SectionUsage in SettingsPage.jsx owns a 30-day request and inline bars. The exis - Use a body-portal tooltip with measured viewport placement, existing anchoredMenuPosition helper, native-overlay attribute, exact localized numbers, and hover/focus/tap dismissal. Roving keyboard focus avoids hundreds of Tab stops; arrow keys move through dates or weeks. - Put calendar and usage-summary styles alongside the new component to avoid modifying the already-dirty global theme file. - Match the corrected reference at a maximum content width of 732px. Render the three existing summary values above their labels in one 62px-high bordered strip, with two vertical dividers and no responsive stacking. Preserve auxiliary information in accessible labels and native tooltips. +- 使用情况整页通过统一容器共用 Token 活动图的 732px 最大宽度与左右自动外边距,在设置内容区水平居中;标题与刷新按钮、三项摘要、活动图、六项 Token 统计、模型明细、工作区明细及提示状态都保持相同的左右边界。窄窗口下填满可用宽度,保留 Token 统计原有两列/六列断点。 - Use an SVG viewBox for the heading, mode controls, entire dot matrix, and month axis. Compute equal square cells at approximately 11px with 3px gaps and 3px corner radii at the maximum width; reduce all geometry uniformly at narrower widths. Keep every in-range date, including when the calendar has 53 columns rather than the reference's 52. Render the last twelve month names at twelve equal axis positions, as explicitly requested, rather than variable-width calendar-month boundaries. Exact dates remain available per cell. Remove the visible range and intensity legend, retaining the cumulative range in accessible descriptions and tooltips. ## Risks / Trade-offs diff --git a/openspec/changes/replace-usage-trend-with-heatmap/specs/web-usage-heatmap/spec.md b/openspec/changes/replace-usage-trend-with-heatmap/specs/web-usage-heatmap/spec.md index 6e984972f..f8f11cfab 100644 --- a/openspec/changes/replace-usage-trend-with-heatmap/specs/web-usage-heatmap/spec.md +++ b/openspec/changes/replace-usage-trend-with-heatmap/specs/web-usage-heatmap/spec.md @@ -36,6 +36,10 @@ Cell intensity SHALL use the current theme accent, empty cells SHALL use neutral - **WHEN** the settings content becomes narrower or wider - **THEN** the summary remains one row, all twelve month labels remain equally spaced, daily cells remain square, the chart fits the available width without horizontal scrolling, and increasing available width beyond 732 pixels does not enlarge the chart +#### Scenario: 使用情况整页居中 +- **WHEN** 用户查看使用情况页面 +- **THEN** 使用情况整页 SHALL 在设置内容区水平居中,标题与刷新按钮、三项摘要、Token 活动图、六项 Token 统计、模型明细、工作区明细及加载、错误、空数据提示共用最大宽度 732 CSS 像素与一致的左右边界;窄窗口下填满可用宽度且无横向溢出 + #### Scenario: Annual request fails - **WHEN** the annual request fails while the 30-day summary succeeds - **THEN** the summary remains available and the calendar reports the failure rather than displaying zero consumption diff --git a/openspec/changes/replace-usage-trend-with-heatmap/tasks.md b/openspec/changes/replace-usage-trend-with-heatmap/tasks.md index edfc2d532..f924345b0 100644 --- a/openspec/changes/replace-usage-trend-with-heatmap/tasks.md +++ b/openspec/changes/replace-usage-trend-with-heatmap/tasks.md @@ -19,3 +19,17 @@ - [x] 4.3 Run frontend tests/build, strict specification validation, scoped design scan, and prepare the reviewed repair for master while preserving unrelated changes. Verification: all frontend tests passed, production Vite build and regex compatibility scan passed, strict OpenSpec validation passed, scoped Impeccable detector returned no findings, and git diff --check passed. Playwright exercised the real SettingsPage with deterministic mock usage responses at 390, 600, 900, 1280, 1920, and 2560px widths. Assertions verified a single-row summary, 365 square daily cells, twelve equally spaced months, proportional gaps and corner radii, a maximum chart width of 732px, cells below 11px, and no horizontal scrolling. Chinese and English headings and native mode controls fit their scaled SVG frame even at 390px. Daily/weekly/cumulative, hover/focus/click, Enter/Space, Home/End, Escape, built-in light/dark and a custom accent, independent loading/failure, historical-only and empty data, and refresh recovery passed. Final screenshots include the page title and use fixture data; the installed native desktop application was not rebuilt. + +## 5. 居中用量概览区域(阶段验证,范围由第 6 节扩展为整页) + +- [x] 5.1 让摘要、活动图和六项 Token 统计共用最大宽度并水平居中,保留窄窗口布局与下方明细宽度。 +- [x] 5.2 验证宽窄窗口居中与无横向溢出,运行前端测试、构建、严格规范校验和差异检查。 + +居中验证:Playwright 加载真实 SettingsPage 与模拟用量数据,检查 390、900、1280、1920、2560px 的中英文、明暗主题,以及 1920px 展开窗口,共 12 组布局。三部分左右留白差小于 1px,宽度均为可用宽度与 732px 的较小值;保留六项统计和 365 个日格,无横向溢出,下方明细仍占满内容宽度。390px 同时使用减少动态效果偏好。pnpm test、pnpm build、严格 OpenSpec 校验和 git diff --check 通过,布局检测无发现。截图使用模拟数据,未重新打包桌面应用。 + +## 6. 使用情况整页居中 + +- [x] 6.1 将标题、刷新按钮、概览、模型与工作区明细以及提示状态放入统一的 732px 居中容器。 +- [x] 6.2 验证宽窄窗口、中英文、展开窗口与提示状态的对齐和无横向溢出,运行前端测试、构建、严格规范校验和差异检查。 + +整页验证:Playwright 加载真实 SettingsPage 与模拟数据,检查 390、900、1280、1920、2560px 的中英文布局、1920px 展开窗口以及加载、空数据、错误状态,共 14 组。整页宽度等于可用宽度与 732px 的较小值,所有直属内容左右边界与整页一致,左右留白差小于 1px,无横向溢出;有数据时保留 365 个日格。验证等待设置窗口入场动画结束后测量,全程启用减少动态效果偏好。pnpm test、pnpm build、严格 OpenSpec 校验与 git diff --check 通过,布局检测无发现。截图使用模拟数据,未重新打包桌面应用。 diff --git a/openspec/changes/review-pr60-pr61-integration/.openspec.yaml b/openspec/changes/review-pr60-pr61-integration/.openspec.yaml new file mode 100644 index 000000000..f2cbbe6a6 --- /dev/null +++ b/openspec/changes/review-pr60-pr61-integration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-18 diff --git a/openspec/changes/review-pr60-pr61-integration/design.md b/openspec/changes/review-pr60-pr61-integration/design.md new file mode 100644 index 000000000..dc5c4b3c6 --- /dev/null +++ b/openspec/changes/review-pr60-pr61-integration/design.md @@ -0,0 +1,20 @@ +## Context + +当前 PR 检查仅覆盖 Linux。图形安装器使用 `.acecode-install.lock`,更新器使用 `.ACECode.update.lock`;前者打开既有 FIFO 时可能阻塞。顶部导航四个 24px 按钮与三个 4px 间距已占满原来的 108px。 + +## Goals / Non-Goals + +完成两个 PR 的审核、必要修复和合并。生产签名、公证、干净非管理员 Mac 的端到端安装仍属于实际发布验收,本任务不执行发布。 + +## Decisions + +- 为顶部导航定义统一最小宽度,将新增 8px 左边距计入桌面及窄窗口规则。 +- 图形安装器沿用既有更新器锁名与锁安全策略:非阻塞、禁止跟随符号链接,检查普通文件、单硬链接及当前用户属主,保持锁 inode 不删除。 +- 使用真实文件系统故障注入验证互斥、异常锁、复制失败与回滚;在 GitHub macOS runner 运行原生 Swift 测试及双架构 UI 编译。 +- `NSApplication.shared` 必须先于含窗口属性的 UI delegate 构造;原生冒烟测试运行完整应用包并检查个人/系统页面截图,覆盖编译无法发现的启动错误。 +- 本次 macOS 自定义目录和双架构 DMG 规范取代 `add-macos-self-update`、`allow-release-without-macos-pkg` 等旧变更里的目录白名单和禁用 DMG 条款;签名、版本、更新 ZIP 校验与可选 PKG 规则继续适用。 + +## Risks / Trade-offs + +- 共享目录内他人拥有的锁会被明确拒绝,避免对不可信锁操作;用户可选择个人可写目录。 +- 离线测试能验证顺序和失败行为,不能证明生产公证或 Gatekeeper 下载隔离行为;发布文档保留这些验收限制。 diff --git a/openspec/changes/review-pr60-pr61-integration/proposal.md b/openspec/changes/review-pr60-pr61-integration/proposal.md new file mode 100644 index 000000000..541ed87eb --- /dev/null +++ b/openspec/changes/review-pr60-pr61-integration/proposal.md @@ -0,0 +1,22 @@ +## Why + +审核 PR #60 与 #61 时发现顶部导航新增左边距没有计入最小宽度,且 macOS 图形安装器与应用内更新器没有共用安装锁。需要在合并前修复边界问题,并让原生安装器回归测试进入 PR 检查。 + +## What Changes + +- 保留侧栏图标对齐,修正窄窗口顶部导航的宽度预算。 +- 支持可信应用在可写自定义目录内原位更新,以及带原生安装器的双架构签名 DMG。 +- 图形安装器与应用内更新共用锁文件,拒绝异常文件类型、链接和不安全属主,获取锁时不得阻塞。 +- 在 PR CI 中运行 macOS 原生安装器测试、双架构编译及离线发布脚本测试。 + +## Capabilities + +### New Capabilities +- `macos-graphical-installation`: 自定义安装位置、可信 DMG、互斥安装与失败恢复;此变更取代旧变更中“仅标准目录”及“禁止 DMG”的限制。 + +### Modified Capabilities +无。 + +## Impact + +涉及侧栏与顶部导航样式、macOS 安装器及更新器、打包工作流、脚本测试和发布文档;不发布版本、不调用生产签名或公证凭据。 diff --git a/openspec/changes/review-pr60-pr61-integration/specs/macos-graphical-installation/spec.md b/openspec/changes/review-pr60-pr61-integration/specs/macos-graphical-installation/spec.md new file mode 100644 index 000000000..e615d8fb3 --- /dev/null +++ b/openspec/changes/review-pr60-pr61-integration/specs/macos-graphical-installation/spec.md @@ -0,0 +1,45 @@ +## Purpose + +让用户通过可信的原生图形安装器将 ACECode 安装到个人、系统或自选可写目录,并在自定义位置安全更新;同时规定安装并发、异常输入、失败恢复与正式发布资产的可验证行为。 + +## ADDED Requirements + +### Requirement: 支持安全自定义安装和原位更新 +安装器 SHALL 提供个人、系统和自定义目录选择,并仅使用当前用户权限。更新器 MUST 接受可写目录中的真实规范化 ACECode.app,拒绝符号链接、应用包嵌套及 App Translocation,并保留签名、团队、版本校验。 + +#### Scenario: 自定义目录安装后更新 +- **WHEN** 用户选择可写自定义目录并安装可信应用 +- **THEN** 应用可在原位置更新,无需管理员授权或迁移用户配置 + +#### Scenario: 不安全更新目标 +- **WHEN** 更新目标是符号链接、嵌套应用或没有父目录写权限 +- **THEN** 更新在替换文件前失败并给出可操作错误 + +### Requirement: 所有应用替换入口相互排斥 +图形安装器和应用内更新器 MUST 对同一安装目录互斥执行;无法安全取得锁时 MUST 及时失败,保留原应用。 + +#### Scenario: 更新与手动安装并发 +- **WHEN** 应用内更新或另一图形安装已持有该目录的锁 +- **THEN** 新安装失败,且不会复制或移动现有应用 + +#### Scenario: 锁路径不是安全普通文件 +- **WHEN** 锁路径为 FIFO、目录、符号链接、多硬链接或其他用户拥有的文件 +- **THEN** 安装及时拒绝,不永久等待且不修改现有应用 + +### Requirement: 替换失败保留可恢复应用 +安装器 SHALL 在同一目录暂存并验证候选应用;确认替换且目标未变化后才移动旧应用。提交失败 MUST 恢复旧版本;恢复失败 MUST 保留备份并报告路径。 + +#### Scenario: 提交失败 +- **WHEN** 旧应用已备份但候选应用提交失败 +- **THEN** 自动恢复旧应用;若恢复也失败则保留并报告备份位置 + +### Requirement: 正式发布提供双架构可信图形安装器 +正式 Release MUST 包含准确匹配版本的 x64 和 arm64 签名、公证、staple 的 DMG,内部应用 MUST 与对应架构已签名应用一致。更新资产 SHALL 继续使用精确匹配的更新 ZIP;PKG 仍为可选双架构资产。 + +#### Scenario: 资产集合错误 +- **WHEN** DMG 缺失、多余、重复、版本不符或含 unsigned 后缀 +- **THEN** Release 收集失败,不发布不完整安装器 + +#### Scenario: 安装器签名改变内部应用 +- **WHEN** 封装或签名导致内部已签名应用内容变化 +- **THEN** 构建失败,不发布该 DMG diff --git a/openspec/changes/review-pr60-pr61-integration/tasks.md b/openspec/changes/review-pr60-pr61-integration/tasks.md new file mode 100644 index 000000000..559d214ae --- /dev/null +++ b/openspec/changes/review-pr60-pr61-integration/tasks.md @@ -0,0 +1,15 @@ +## 1. 侧栏对齐修复 + +- [x] 1.1 计入顶部按钮新增左边距,通过 390px 和桌面宽度浏览器布局验证。 +- [x] 1.2 运行 Web 全量测试及生产构建,审核并合并 PR #60。 + +## 2. macOS 安装修复 + +- [ ] 2.1 统一安装器与更新器锁,并通过并发及异常锁回归测试。 +- [ ] 2.2 为 PR 添加 macOS 原生测试及双架构编译,确认远端检查通过。 +- [x] 2.3 完成更新路径、重启、发布流程审核,运行针对性 C++ 及离线脚本测试,更新发布文档。 +- [ ] 2.4 在创建窗口前初始化 AppKit,并通过真实应用包的个人/系统页面启动截图测试。 + +## 3. 集成验证 + +- [ ] 3.1 严格验证 OpenSpec 与差异,完成 PR #61 合并,核对工作区与远端提交一致。 diff --git a/openspec/changes/review-pr60-pr61-integration/validation.md b/openspec/changes/review-pr60-pr61-integration/validation.md new file mode 100644 index 000000000..50fb00498 --- /dev/null +++ b/openspec/changes/review-pr60-pr61-integration/validation.md @@ -0,0 +1,14 @@ +## 本地验证 + +- PR #60:`pnpm test`、`pnpm build` 通过。使用真实 TopBar 组件和生产 CSS 在 Chromium 验证 390px / 1280px、侧栏展开 / 折叠、中英文和 reduced-motion,共 8 种组合。原 PR 在窄窗口或折叠状态溢出 8px,修复后全部包含于导航区域。 +- PR #61:WSL 中编译并通过 8 项 `MacosBundleLayout`、`MacosBundleInstallPath`、`DesktopRestart` 测试。 +- 对 Git index 的完整快照运行 9 项安装器发布测试、4 项便携包测试、发布 Shell 契约检查和新增脚本语法检查,全部通过。Windows 的 `core.autocrlf` 会将归档转为 CRLF;验证快照使用 `git -c core.autocrlf=false archive` 保持 Git 中的 LF。 +- OpenSpec 严格验证与 `git diff --check` 通过。 + +## 远端原生验证 + +新增 `macos-installer-tests` PR 检查,用于原生 Swift 故障/锁测试、两个架构的 UI 编译及离线发布测试。结果将在完成后记录。 + +## 验证范围 + +没有调用生产证书或 Apple 公证服务,也没有构建发布包。真实签名安装、下载隔离、非管理员干净机器及 macOS 11 运行验收继续按照发布文档执行。 diff --git a/packaging/macos-installer/Engine.swift b/packaging/macos-installer/Engine.swift new file mode 100644 index 000000000..52bca5e43 --- /dev/null +++ b/packaging/macos-installer/Engine.swift @@ -0,0 +1,180 @@ +import Foundation +import Darwin + +struct InstallError: LocalizedError { + let message: String + var errorDescription: String? { message } +} + +func runTool(_ path: String, _ arguments: [String]) throws -> String { + let task = Process() + task.executableURL = URL(fileURLWithPath: path) + task.arguments = arguments + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + try task.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + let output = String(data: data, encoding: .utf8) ?? "" + guard task.terminationStatus == 0 else { + throw InstallError(message: "\(URL(fileURLWithPath: path).lastPathComponent) 失败:\n\(output)") + } + return output +} + +enum InstallScope { case personal, system, custom } + +final class InstallerEngine { + let fm = FileManager.default + let home: URL + let validateSignature: (URL) throws -> Void + // Injected faults are used only by the standalone test executable. + var fault: ((String) throws -> Void)? + var isRunning: (URL) -> Bool = { _ in false } + private(set) var cleanupWarning: String? + + init(home: URL = FileManager.default.homeDirectoryForCurrentUser, + validateSignature: @escaping (URL) throws -> Void = InstallerEngine.trustedSignature) { + self.home = home.resolvingSymlinksInPath().standardizedFileURL + self.validateSignature = validateSignature + } + + static func trustedSignature(_ app: URL) throws { + _ = try runTool("/usr/bin/codesign", ["--verify", "--deep", "--strict", app.path]) + let details = try runTool("/usr/bin/codesign", ["-dv", "--verbose=4", app.path]) + guard details.contains("TeamIdentifier=T52GZCH73Y"), + details.contains("Authority=Developer ID Application:") else { + throw InstallError(message: "应用不是预期发行团队的 Developer ID 签名版本。") + } + _ = try runTool("/usr/sbin/spctl", ["--assess", "--type", "execute", app.path]) + } + + func bundleCheck(_ app: URL) throws { + let info = app.appendingPathComponent("Contents/Info.plist") + guard let data = try? Data(contentsOf: info), + let dict = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + dict["CFBundleIdentifier"] as? String == "dev.acecode.desktop", + let executable = dict["CFBundleExecutable"] as? String, + !executable.isEmpty, !executable.contains("/"), executable != "..", + fm.isExecutableFile(atPath: app.appendingPathComponent("Contents/MacOS/" + executable).path) else { + throw InstallError(message: "不是完整的 ACECode 应用:\(app.path)") + } + } + + func checkedFolder(_ input: URL, create: Bool, scope: InstallScope = .personal) throws -> URL { + let folder = input.resolvingSymlinksInPath().standardizedFileURL + if scope == .personal && !folder.path.hasPrefix(home.path + "/") { + throw InstallError(message: "个人安装请选择主目录内的文件夹;系统目录请使用“这台 Mac”选项。") + } + if scope == .system && folder != URL(fileURLWithPath: "/Applications").resolvingSymlinksInPath() { + throw InstallError(message: "系统安装仅支持 /Applications。") + } + guard !folder.pathComponents.contains(where: { $0.lowercased().hasSuffix(".app") }) else { + throw InstallError(message: "不能安装到另一个应用包内部。") + } + var ancestor = folder + while !fm.fileExists(atPath: ancestor.path) { ancestor.deleteLastPathComponent() } + let attrs = try fm.attributesOfItem(atPath: ancestor.path) + guard attrs[.type] as? FileAttributeType == .typeDirectory, + fm.isWritableFile(atPath: ancestor.path), + fm.isExecutableFile(atPath: ancestor.path) else { + throw InstallError(message: "当前用户没有此目录的写入或访问权限:\(ancestor.path)\n本安装器不会请求提权。请选择“仅为我安装”,或更换可写位置。") + } + if create { try fm.createDirectory(at: folder, withIntermediateDirectories: true) } + return folder + } + + func suggestedFolder() -> URL { + let first = home.appendingPathComponent("Applications") + return (try? checkedFolder(first, create: false)) != nil ? first : home.appendingPathComponent("Apps") + } + + func install(source: URL, folder: URL, replace: Bool, scope: InstallScope = .personal, + progress: (String) -> Void = { _ in }) throws -> URL { + cleanupWarning = nil + progress("正在验证应用签名与系统信任…") + try bundleCheck(source) + try validateSignature(source) + let directory = try checkedFolder(folder, create: true, scope: scope) + let target = directory.appendingPathComponent("ACECode.app") + guard source.resolvingSymlinksInPath().path != target.resolvingSymlinksInPath().path, + !directory.path.hasPrefix(source.resolvingSymlinksInPath().path + "/") else { + throw InstallError(message: "源应用与目标不能相同,也不能安装到源应用内部。") + } + // Share the lock and its safety contract with src/upgrade/macos_app_installer.mm. + let lock = directory.appendingPathComponent(".ACECode.update.lock") + let fd = open(lock.path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK, mode_t(0o600)) + guard fd >= 0 else { throw InstallError(message: "无法创建安装锁,请检查目录权限。") } + defer { close(fd) } + var lockInfo = stat() + guard fstat(fd, &lockInfo) == 0, + (lockInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG), + lockInfo.st_uid == geteuid(), lockInfo.st_nlink == 1 else { + throw InstallError(message: "安装锁必须是当前用户拥有的普通文件,且不能包含多个硬链接。") + } + guard flock(fd, LOCK_EX | LOCK_NB) == 0 else { throw InstallError(message: "另一个安装或更新程序正在使用此目录。") } + defer { flock(fd, LOCK_UN) } + // Keep the lock inode: unlinking it allows two processes to lock different inodes. + let original = try targetState(target) + if original != nil && !replace { throw InstallError(message: "目标已有 ACECode,请确认替换后再安装。") } + if original != nil { try bundleCheck(target) } + if original != nil && !fm.isDeletableFile(atPath: target.path) { + throw InstallError(message: "当前用户不能替换已有应用:\(target.path)\n请改用个人目录安装。") + } + guard !isRunning(target) else { throw InstallError(message: "目标 ACECode 正在运行,请退出后重试。") } + let work = directory.appendingPathComponent(".acecode-stage-" + UUID().uuidString) + try fm.createDirectory(at: work, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + let staged = work.appendingPathComponent("ACECode.app") + let backup = work.appendingPathComponent("previous.app") + var preserve = false + defer { if !preserve { try? fm.removeItem(at: work) } } + progress("正在复制应用到临时目录…") + try fault?("copy") + _ = try runTool("/usr/bin/ditto", [source.path, staged.path]) + try bundleCheck(staged) + try validateSignature(staged) + guard try targetState(target) == original, !isRunning(target) else { + throw InstallError(message: "目标应用在安装期间发生变化或已启动,请重试。") + } + progress("正在提交安装…") + if original != nil { try fm.moveItem(at: target, to: backup) } + do { + try fault?("commit") + try fm.moveItem(at: staged, to: target) + } catch { + let commitError = error + if original != nil { + do { + try fault?("rollback") + try fm.moveItem(at: backup, to: target) + } catch { + preserve = true + throw InstallError(message: "安装失败且自动恢复失败。旧版本安全保留在:\(backup.path)\n安装错误:\(commitError.localizedDescription)\n恢复错误:\(error.localizedDescription)") + } + } + throw InstallError(message: "安装未完成,原有版本未改变或已恢复。\n\(commitError.localizedDescription)") + } + // A shared installation may contain old files this user cannot delete. + // Never silently remove a backup when cleanup fails. + do { try fm.removeItem(at: work) } + catch { + preserve = true + cleanupWarning = "安装成功;旧文件未能清理,保留于:\(work.path)" + } + return target + } + + func targetState(_ target: URL) throws -> String? { + do { + let attrs = try fm.attributesOfItem(atPath: target.path) + guard attrs[.type] as? FileAttributeType == .typeDirectory else { + throw InstallError(message: "目标不是普通应用目录,拒绝覆盖链接或其他文件。") + } + return "\(attrs[.systemFileNumber]!)|\(attrs[.modificationDate]!)" + } catch let error as NSError where error.domain == NSCocoaErrorDomain && + (error.code == NSFileNoSuchFileError || error.code == NSFileReadNoSuchFileError) { + return nil + } + } +} diff --git a/packaging/macos-installer/Tests.swift b/packaging/macos-installer/Tests.swift new file mode 100644 index 000000000..02f8ec4bd --- /dev/null +++ b/packaging/macos-installer/Tests.swift @@ -0,0 +1,150 @@ +import Foundation +import Darwin + +@main struct Tests { + static func main() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.resolvingSymlinksInPath().appendingPathComponent("acecode-installer-test-" + UUID().uuidString) + try fm.createDirectory(at: root, withIntermediateDirectories: false) + defer { try? fm.removeItem(at: root) } + let engine = InstallerEngine(home: root, validateSignature: { _ in }) + var count = 0 + func check(_ name: String, _ body: () throws -> Void) throws { + try body(); count += 1; print("PASS \(name)") + } + func rejected(_ body: () throws -> Void) throws { + do { try body() } catch { return } + throw InstallError(message: "Expected failure") + } + func fixture(_ name: String) throws -> URL { + let app = root.appendingPathComponent(name + "/ACECode.app") + let mac = app.appendingPathComponent("Contents/MacOS") + try fm.createDirectory(at: mac, withIntermediateDirectories: true) + let info: [String: String] = ["CFBundleIdentifier": "dev.acecode.desktop", "CFBundleExecutable": "ACECode"] + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: app.appendingPathComponent("Contents/Info.plist")) + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: mac.appendingPathComponent("ACECode")) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: mac.appendingPathComponent("ACECode").path) + return app + } + let source = try fixture("source") + let folder = root.appendingPathComponent("个人 Apps") + let target = folder.appendingPathComponent("ACECode.app") + try check("new install / Chinese and spaces") { _ = try engine.install(source: source, folder: folder, replace: false) } + let marker = target.appendingPathComponent("old-marker") + try Data("old".utf8).write(to: marker) + try check("replacement needs confirmation") { try rejected { _ = try engine.install(source: source, folder: folder, replace: false) } } + try check("copy failure preserves original") { + engine.fault = { if $0 == "copy" { throw InstallError(message: "injected") } } + try rejected { _ = try engine.install(source: source, folder: folder, replace: true) } + assert(fm.fileExists(atPath: marker.path)); engine.fault = nil + } + try check("commit failure rolls back") { + engine.fault = { if $0 == "commit" { throw InstallError(message: "injected") } } + try rejected { _ = try engine.install(source: source, folder: folder, replace: true) } + assert(fm.fileExists(atPath: marker.path)); engine.fault = nil + } + try check("running app rejected") { + engine.isRunning = { _ in true } + try rejected { _ = try engine.install(source: source, folder: folder, replace: true) } + engine.isRunning = { _ in false } + } + try check("concurrent updater lock rejected without changing the app") { + let fd = open(folder.appendingPathComponent(".ACECode.update.lock").path, O_RDWR) + assert(fd >= 0); assert(flock(fd, LOCK_EX | LOCK_NB) == 0) + defer { close(fd) } + try rejected { _ = try engine.install(source: source, folder: folder, replace: true) } + assert(fm.fileExists(atPath: marker.path)) + } + for kind in ["fifo", "directory", "symlink", "hardlink"] { + try check("unsafe \(kind) lock rejected before copying") { + let destination = root.appendingPathComponent("lock-" + kind) + try fm.createDirectory(at: destination, withIntermediateDirectories: false) + let lock = destination.appendingPathComponent(".ACECode.update.lock") + let sentinel = destination.appendingPathComponent("sentinel") + try Data("unchanged".utf8).write(to: sentinel) + switch kind { + case "fifo": assert(mkfifo(lock.path, mode_t(0o600)) == 0) + case "directory": try fm.createDirectory(at: lock, withIntermediateDirectories: false) + case "symlink": try fm.createSymbolicLink(at: lock, withDestinationURL: sentinel) + default: try fm.linkItem(at: sentinel, to: lock) + } + var copied = false + engine.fault = { if $0 == "copy" { copied = true } } + defer { engine.fault = nil } + try rejected { _ = try engine.install(source: source, folder: destination, replace: false) } + assert(!copied) + let sentinelContents = try String(contentsOf: sentinel, encoding: .utf8) + assert(sentinelContents == "unchanged") + assert(!fm.fileExists(atPath: destination.appendingPathComponent("ACECode.app").path)) + } + } + try check("successful replacement") { + _ = try engine.install(source: source, folder: folder, replace: true) + assert(!fm.fileExists(atPath: marker.path)) + } + try check("unwritable directory and fallback") { + let apps = root.appendingPathComponent("Applications") + try fm.createDirectory(at: apps, withIntermediateDirectories: false) + try fm.setAttributes([.posixPermissions: 0o555], ofItemAtPath: apps.path) + defer { try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: apps.path) } + assert(engine.suggestedFolder().lastPathComponent == "Apps") + try rejected { _ = try engine.checkedFolder(apps, create: true) } + } + try check("outside home and symlink escape") { + try rejected { _ = try engine.checkedFolder(URL(fileURLWithPath: "/Applications"), create: false) } + let link = root.appendingPathComponent("escape") + try fm.createSymbolicLink(at: link, withDestinationURL: URL(fileURLWithPath: "/tmp")) + try rejected { _ = try engine.checkedFolder(link, create: false) } + } + try check("system directory uses actual access, not owner identity (read only)") { + let system = URL(fileURLWithPath: "/Applications") + if fm.isWritableFile(atPath: system.path) && fm.isExecutableFile(atPath: system.path) { + let checked = try engine.checkedFolder(system, create: false, scope: .system) + assert(checked == system.resolvingSymlinksInPath()) + } else { + try rejected { _ = try engine.checkedFolder(system, create: false, scope: .system) } + } + try rejected { _ = try engine.checkedFolder(folder, create: false, scope: .system) } + } + try check("custom install outside configured home") { + let limited = InstallerEngine(home: root.appendingPathComponent("fake-home"), validateSignature: { _ in }) + let custom = root.appendingPathComponent("shared-location") + try rejected { _ = try limited.checkedFolder(custom, create: false) } + _ = try limited.install(source: source, folder: custom, replace: false, scope: .custom) + assert(fm.fileExists(atPath: custom.appendingPathComponent("ACECode.app").path)) + } + try check("custom unwritable directory and app nesting rejected") { + let denied = root.appendingPathComponent("denied") + try fm.createDirectory(at: denied, withIntermediateDirectories: false) + try fm.setAttributes([.posixPermissions: 0o555], ofItemAtPath: denied.path) + defer { try? fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: denied.path) } + try rejected { _ = try engine.checkedFolder(denied, create: true, scope: .custom) } + try rejected { _ = try engine.checkedFolder(source.appendingPathComponent("nested"), create: false, scope: .custom) } + } + try check("unrelated app and missing source") { + let other = root.appendingPathComponent("other/ACECode.app") + try fm.createDirectory(at: other, withIntermediateDirectories: true) + try rejected { _ = try engine.install(source: source, folder: other.deletingLastPathComponent(), replace: true) } + try rejected { _ = try engine.install(source: root.appendingPathComponent("missing"), folder: folder, replace: true) } + } + try check("invalid signature") { + let strict = InstallerEngine(home: root) + try rejected { _ = try strict.install(source: source, folder: folder, replace: true) } + } + try check("rollback failure preserves backup") { + engine.fault = { if $0 == "commit" || $0 == "rollback" { throw InstallError(message: "injected") } } + try rejected { _ = try engine.install(source: source, folder: folder, replace: true) } + let dirs = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) + assert(dirs.contains { fm.fileExists(atPath: $0.appendingPathComponent("previous.app").path) }) + engine.fault = nil + } + if CommandLine.arguments.count > 1 { + try check("real Developer ID payload install and signature verification") { + let strict = InstallerEngine(home: root) + let result = try strict.install(source: URL(fileURLWithPath: CommandLine.arguments[1]), folder: root.appendingPathComponent("real"), replace: false) + try InstallerEngine.trustedSignature(result) + } + } + print("\(count) tests passed; temporary test directories removed on exit") + } +} diff --git a/packaging/macos-installer/build.sh b/packaging/macos-installer/build.sh new file mode 100644 index 000000000..ce915a1b4 --- /dev/null +++ b/packaging/macos-installer/build.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Production adaptation of the v6 local installer build; never signs the payload. +set -euo pipefail +usage() { + printf 'Usage: bash packaging/macos-installer/build.sh --app ACECode.app --output NEW-DIR --arch x64|arm64\n' +} +source_app=""; output=""; arch="" +while [[ $# -gt 0 ]]; do + case "$1" in + --app|--output|--arch) + [[ $# -ge 2 && -n "$2" ]] || { usage >&2; exit 2; } + case "$1" in --app) source_app="$2";; --output) output="$2";; --arch) arch="$2";; esac + shift 2 ;; + --help|-h) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac +done +case "$arch" in x64) target=x86_64;; arm64) target=arm64;; *) usage >&2; exit 2;; esac +[[ -n "$source_app" && -n "$output" ]] || { usage >&2; exit 2; } +[[ "$(uname -s)" == Darwin ]] || { printf 'Darwin required\n' >&2; exit 1; } +[[ -d "$source_app" && "$(basename "$source_app")" == ACECode.app && ! -e "$output" && ! -L "$output" ]] || { + printf 'Source missing or output already exists\n' >&2; exit 1; +} +source_app="$(cd "$source_app" && pwd -P)" +source_dir="$(cd "$(dirname "$0")" && pwd)" +info="$source_app/Contents/Info.plist" +[[ "$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$info")" == dev.acecode.desktop ]] +version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$info")" +build_version="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$info")" +for binary in ACECode acecode-daemon; do + /usr/bin/lipo "$source_app/Contents/MacOS/$binary" -verify_arch "$target" +done +/usr/bin/codesign --verify --deep --strict "$source_app" +details="$(/usr/bin/codesign -dv --verbose=4 "$source_app" 2>&1)" +grep -Fxq 'TeamIdentifier=T52GZCH73Y' <<< "$details" +grep -Fq 'Authority=Developer ID Application:' <<< "$details" +xcrun stapler validate "$source_app" +/usr/sbin/spctl --assess --type execute "$source_app" +mkdir -p "$output" +output="$(cd "$output" && pwd -P)" +app="$output/ACECode Installer.app" +mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" +xcrun swiftc -swift-version 5 -O -target "${target}-apple-macosx11.0" \ + -sdk "$(xcrun --sdk macosx --show-sdk-path)" -framework AppKit \ + "$source_dir/Engine.swift" "$source_dir/main.swift" -o "$app/Contents/MacOS/ACECodeInstaller" +/usr/bin/lipo "$app/Contents/MacOS/ACECodeInstaller" -verify_arch "$target" +plist="$app/Contents/Info.plist" +/usr/bin/plutil -create xml1 "$plist" +/usr/bin/plutil -insert CFBundleIdentifier -string dev.acecode.userinstaller "$plist" +/usr/bin/plutil -insert CFBundleExecutable -string ACECodeInstaller "$plist" +/usr/bin/plutil -insert CFBundleName -string 'ACECode Installer' "$plist" +/usr/bin/plutil -insert CFBundlePackageType -string APPL "$plist" +/usr/bin/plutil -insert CFBundleVersion -string "$build_version" "$plist" +/usr/bin/plutil -insert CFBundleShortVersionString -string "$version" "$plist" +/usr/bin/plutil -insert LSMinimumSystemVersion -string 11.0 "$plist" +icon_name="$(/usr/bin/plutil -extract CFBundleIconFile raw -o - "$info")" +[[ -n "$icon_name" && "$icon_name" != */* && "$icon_name" != .* ]] || { printf 'Invalid app icon name\n' >&2; exit 1; } +[[ "$icon_name" == *.icns ]] || icon_name="$icon_name.icns" +/usr/bin/ditto "$source_app/Contents/Resources/$icon_name" "$app/Contents/Resources/InstallerIcon.icns" +/usr/bin/plutil -insert CFBundleIconFile -string InstallerIcon.icns "$plist" +/usr/bin/ditto "$source_app" "$app/Contents/Resources/ACECode.app" +/usr/bin/diff -rq "$source_app" "$app/Contents/Resources/ACECode.app" +printf 'Unsigned installer wrapper (trusted payload unchanged): %s\n' "$app" diff --git a/packaging/macos-installer/main.swift b/packaging/macos-installer/main.swift new file mode 100644 index 000000000..bf3a65be2 --- /dev/null +++ b/packaging/macos-installer/main.swift @@ -0,0 +1,271 @@ +import AppKit + +final class InstallerUI: NSObject, NSApplicationDelegate { + let engine = InstallerEngine() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 820, height: 600), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false) + let scopePicker = NSSegmentedControl(labels: ["仅为我安装", "这台 Mac", "其他位置"], trackingMode: .selectOne, target: nil, action: nil) + let locationTitle = NSTextField(labelWithString: "个人应用目录") + var scope: InstallScope { scopePicker.selectedSegment == 1 ? .system : scopePicker.selectedSegment == 2 ? .custom : .personal } + let heading = NSTextField(labelWithString: "安装 ACECode") + let detail = NSTextField(wrappingLabelWithString: "选择安装位置。") + let stepLabels = ["安装位置", "正在安装", "安装完成"].map { NSTextField(labelWithString: $0) } + let hero = NSImageView() + let path = NSTextField(wrappingLabelWithString: "") + let status = NSTextField(wrappingLabelWithString: "无需管理员密码,不修改系统目录或现有用户数据。") + let change = NSButton(title: "选择其他文件夹…", target: nil, action: nil) + let install = NSButton(title: "安装到个人目录", target: nil, action: nil) + let launch = NSButton(title: "启动 ACECode", target: nil, action: nil) + let reveal = NSButton(title: "在 Finder 中显示", target: nil, action: nil) + let spinner = NSProgressIndicator() + var destination: URL! + var installed: URL? + var busy = false + var source: URL { Bundle.main.resourceURL!.appendingPathComponent("ACECode.app") } + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + if let iconURL = Bundle.main.url(forResource: "InstallerIcon", withExtension: "icns"), + let icon = NSImage(contentsOf: iconURL) { + NSApp.applicationIconImage = icon + } + let menu = NSMenu() + let root = NSMenuItem() + let appMenu = NSMenu() + appMenu.addItem(withTitle: "退出安装器", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + root.submenu = appMenu + menu.addItem(root) + NSApp.mainMenu = menu + window.title = "安装 ACECode" + window.isReleasedWhenClosed = false + let version = Bundle(url: source)?.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "未知" + heading.font = .boldSystemFont(ofSize: 25) + detail.font = .systemFont(ofSize: 13) + detail.textColor = .secondaryLabelColor + path.isSelectable = true + path.font = .systemFont(ofSize: 12) + path.lineBreakMode = .byTruncatingMiddle + path.maximumNumberOfLines = 2 + status.isSelectable = true + status.font = .systemFont(ofSize: 12) + status.textColor = .secondaryLabelColor + change.target = self; change.action = #selector(selectFolder) + change.title = "更改位置…" + change.bezelStyle = .rounded + install.target = self; install.action = #selector(startInstall) + install.title = "安装" + install.bezelStyle = .rounded + install.keyEquivalent = "\r" + launch.target = self; launch.action = #selector(openApp) + reveal.target = self; reveal.action = #selector(showApp) + launch.bezelStyle = .rounded; reveal.bezelStyle = .rounded + launch.isHidden = true; reveal.isHidden = true + spinner.style = .bar; spinner.isIndeterminate = true; spinner.isDisplayedWhenStopped = false + let content = window.contentView! + let sidebar = NSVisualEffectView() + sidebar.material = .sidebar; sidebar.blendingMode = .behindWindow; sidebar.state = .active + let footer = NSView() + let separator = NSBox(); separator.boxType = .separator + let divider = NSBox(); divider.boxType = .separator + for view in [sidebar, footer, separator, divider] { + view.translatesAutoresizingMaskIntoConstraints = false; content.addSubview(view) + } + hero.image = NSWorkspace.shared.icon(forFile: source.path) + hero.imageScaling = .scaleProportionallyUpOrDown + let brand = NSTextField(labelWithString: "ACECode") + brand.font = .boldSystemFont(ofSize: 17) + let badge = NSTextField(labelWithString: "版本 \(version)") + badge.font = .systemFont(ofSize: 11); badge.textColor = .secondaryLabelColor + let sideStack = NSStackView(views: [hero, brand, badge]) + sideStack.orientation = .vertical; sideStack.alignment = .leading; sideStack.spacing = 10 + sideStack.translatesAutoresizingMaskIntoConstraints = false; sidebar.addSubview(sideStack) + let steps = NSStackView(views: stepLabels) + steps.orientation = .vertical; steps.alignment = .leading; steps.spacing = 22 + steps.translatesAutoresizingMaskIntoConstraints = false; sidebar.addSubview(steps) + + let card = NSBox() + card.boxType = .custom; card.borderWidth = 1; card.cornerRadius = 10 + card.borderColor = .separatorColor; card.fillColor = .controlBackgroundColor + card.contentViewMargins = NSSize(width: 16, height: 16) + let folderIcon = NSImageView(image: NSImage(systemSymbolName: "folder.fill", accessibilityDescription: "个人应用目录")!) + folderIcon.contentTintColor = .systemBlue + locationTitle.font = .boldSystemFont(ofSize: 13) + let locationText = NSStackView(views: [locationTitle, path]) + locationText.orientation = .vertical; locationText.alignment = .leading; locationText.spacing = 6 + let locationRow = NSStackView(views: [folderIcon, locationText]) + locationRow.spacing = 12; locationRow.alignment = .centerY + let cardStack = NSStackView(views: [locationRow, change]) + cardStack.orientation = .vertical; cardStack.alignment = .leading; cardStack.spacing = 12 + cardStack.translatesAutoresizingMaskIntoConstraints = false; card.contentView!.addSubview(cardStack) + scopePicker.selectedSegment = 0 + scopePicker.target = self; scopePicker.action = #selector(changeScope) + let stack = NSStackView(views: [heading, detail, scopePicker, card, status, spinner]) + stack.orientation = .vertical; stack.alignment = .leading; stack.spacing = 20 + stack.translatesAutoresizingMaskIntoConstraints = false; content.addSubview(stack) + let actions = NSStackView(views: [reveal, launch, install]) + actions.spacing = 8; actions.translatesAutoresizingMaskIntoConstraints = false; footer.addSubview(actions) + NSLayoutConstraint.activate([ + sidebar.leadingAnchor.constraint(equalTo: content.leadingAnchor), sidebar.topAnchor.constraint(equalTo: content.topAnchor), + sidebar.bottomAnchor.constraint(equalTo: footer.topAnchor), sidebar.widthAnchor.constraint(equalToConstant: 196), + footer.leadingAnchor.constraint(equalTo: content.leadingAnchor), footer.trailingAnchor.constraint(equalTo: content.trailingAnchor), + footer.bottomAnchor.constraint(equalTo: content.bottomAnchor), footer.heightAnchor.constraint(equalToConstant: 64), + separator.leadingAnchor.constraint(equalTo: content.leadingAnchor), separator.trailingAnchor.constraint(equalTo: content.trailingAnchor), + separator.topAnchor.constraint(equalTo: footer.topAnchor), + divider.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor), divider.topAnchor.constraint(equalTo: content.topAnchor), + divider.bottomAnchor.constraint(equalTo: footer.topAnchor), divider.widthAnchor.constraint(equalToConstant: 1), + sideStack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 26), sideStack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 32), + hero.widthAnchor.constraint(equalToConstant: 64), hero.heightAnchor.constraint(equalToConstant: 64), + steps.leadingAnchor.constraint(equalTo: sideStack.leadingAnchor), steps.topAnchor.constraint(equalTo: sideStack.bottomAnchor, constant: 44), + stack.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: 36), stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -36), + stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 38), stack.bottomAnchor.constraint(lessThanOrEqualTo: footer.topAnchor, constant: -20), + card.widthAnchor.constraint(equalTo: stack.widthAnchor), detail.widthAnchor.constraint(equalTo: stack.widthAnchor), + status.widthAnchor.constraint(equalTo: stack.widthAnchor), spinner.widthAnchor.constraint(equalTo: stack.widthAnchor), + cardStack.leadingAnchor.constraint(equalTo: card.contentView!.leadingAnchor), cardStack.trailingAnchor.constraint(equalTo: card.contentView!.trailingAnchor), + cardStack.topAnchor.constraint(equalTo: card.contentView!.topAnchor), cardStack.bottomAnchor.constraint(equalTo: card.contentView!.bottomAnchor), + locationRow.widthAnchor.constraint(equalTo: cardStack.widthAnchor), folderIcon.widthAnchor.constraint(equalToConstant: 30), folderIcon.heightAnchor.constraint(equalToConstant: 30), + actions.trailingAnchor.constraint(equalTo: footer.trailingAnchor, constant: -24), actions.centerYAnchor.constraint(equalTo: footer.centerYAnchor), + install.widthAnchor.constraint(greaterThanOrEqualToConstant: 90) + ]) + destination = engine.suggestedFolder() + if CommandLine.arguments.contains("--preview-system") { + scopePicker.selectedSegment = 1 + destination = URL(fileURLWithPath: "/Applications") + } + refreshPath() + updateStep(0) + engine.isRunning = { target in + NSWorkspace.shared.runningApplications.contains { $0.bundleURL?.resolvingSymlinksInPath() == target.resolvingSymlinksInPath() } + } + window.center(); window.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true) + if let index = CommandLine.arguments.firstIndex(of: "--snapshot"), CommandLine.arguments.count > index + 1 { + let output = CommandLine.arguments[index + 1] + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + content.layoutSubtreeIfNeeded() + if let bitmap = content.bitmapImageRepForCachingDisplay(in: content.bounds) { + content.cacheDisplay(in: content.bounds, to: bitmap) + if let data = bitmap.representation(using: .png, properties: [:]) { + try? data.write(to: URL(fileURLWithPath: output)) + } + } + NSApp.terminate(nil) + } + } + } + + func updateStep(_ active: Int) { + for (index, label) in stepLabels.enumerated() { + let name = ["安装位置", "正在安装", "安装完成"][index] + label.stringValue = "\(index < active ? "✓" : index == active ? "●" : "○") \(name)" + label.textColor = index == active ? .controlAccentColor : .secondaryLabelColor + label.font = index == active ? .boldSystemFont(ofSize: 13) : .systemFont(ofSize: 13) + } + } + + func refreshPath() { + path.stringValue = destination.appendingPathComponent("ACECode.app").path + path.toolTip = path.stringValue + locationTitle.stringValue = scope == .system ? "系统应用程序目录" : scope == .personal ? "个人应用目录" : "自选安装目录" + change.isHidden = scope != .custom + detail.stringValue = scope == .system ? "安装到 /Applications,供这台 Mac 的用户使用。" : "选择安装位置,推荐仅为自己安装。" + if installed == nil { + do { + _ = try engine.checkedFolder(destination, create: false, scope: scope) + install.isEnabled = true + status.textColor = .secondaryLabelColor + status.stringValue = "此位置可以安装。" + } catch { + install.isEnabled = false + status.textColor = .systemOrange + status.stringValue = error.localizedDescription + } + } + } + @objc func changeScope() { + destination = scope == .system ? URL(fileURLWithPath: "/Applications") : engine.suggestedFolder() + refreshPath() + } + @objc func selectFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true; panel.canChooseFiles = false + panel.canCreateDirectories = true; panel.allowsMultipleSelection = false + panel.directoryURL = destination + panel.message = "选择当前账号可写的位置。不会请求管理员授权;不要选择应用包内部。" + if panel.runModal() == .OK, let selected = panel.url { + do { destination = try engine.checkedFolder(selected, create: false, scope: scope); refreshPath() } + catch { showError(error) } + } + } + + @objc func startInstall() { + guard !busy else { return } + let target = destination.appendingPathComponent("ACECode.app") + var replace = false + if FileManager.default.fileExists(atPath: target.path) { + let alert = NSAlert() + alert.messageText = "替换已有 ACECode?" + alert.informativeText = "仅替换 \(target.path),不删除个人配置。请先退出该位置的 ACECode。失败时尝试恢复旧版本。" + alert.addButton(withTitle: "替换"); alert.addButton(withTitle: "取消") + guard alert.runModal() == .alertFirstButtonReturn else { return } + replace = true + } + busy = true; install.isEnabled = false; change.isEnabled = false + scopePicker.isEnabled = false + installed = nil + updateStep(1) + heading.stringValue = "正在安装 ACECode" + detail.stringValue = "正在复制应用,请稍候。" + install.title = "正在安装…" + window.standardWindowButton(.closeButton)?.isEnabled = false + launch.isHidden = true; reveal.isHidden = true + spinner.startAnimation(nil) + let folder = destination!, payload = source, confirmed = replace, selectedScope = scope + DispatchQueue.global(qos: .userInitiated).async { + let result = Result { try self.engine.install(source: payload, folder: folder, replace: confirmed, scope: selectedScope) { text in + DispatchQueue.main.async { self.status.stringValue = text } + } } + DispatchQueue.main.async { + self.busy = false; self.install.isEnabled = true; self.change.isEnabled = true + self.window.standardWindowButton(.closeButton)?.isEnabled = true + self.spinner.stopAnimation(nil) + switch result { + case .success(let url): + self.installed = url + self.updateStep(2) + self.heading.stringValue = "ACECode 已准备就绪" + self.detail.stringValue = "安装完成,可以开始使用了。" + self.status.stringValue = self.engine.cleanupWarning ?? "" + self.install.isHidden = true; self.change.isHidden = true + self.launch.keyEquivalent = "\r" + self.launch.isHidden = false; self.reveal.isHidden = false + case .failure(let error): + self.scopePicker.isEnabled = true + self.updateStep(0) + self.heading.stringValue = "安装未完成" + self.detail.stringValue = "请检查或更换安装位置后重试。" + self.install.title = "重试安装" + self.status.stringValue = "安装未完成。" + self.showError(error) + } + } + } + } + + func showError(_ error: Error) { + let alert = NSAlert(error: error) + alert.runModal() + } + @objc func showApp() { if let url = installed { NSWorkspace.shared.activateFileViewerSelecting([url]) } } + @objc func openApp() { + guard let url = installed else { return } + NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration()) { _, error in + if let error = error { DispatchQueue.main.async { self.showError(error) } } + } + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { busy ? .terminateCancel : .terminateNow } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } +} + +// Initialize AppKit and its window-server connection before creating NSWindow. +let application = NSApplication.shared +let delegate = InstallerUI() +application.delegate = delegate +application.run() diff --git a/scripts/macos_create_installer_dmg.sh b/scripts/macos_create_installer_dmg.sh new file mode 100644 index 000000000..1ac985d50 --- /dev/null +++ b/scripts/macos_create_installer_dmg.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x +usage() { + printf 'Usage: bash scripts/macos_create_installer_dmg.sh --app ACECode.app --output NEW.dmg --arch x64|arm64 --identity APPLICATION-IDENTITY [--keychain PATH] [--keychain-profile NAME]\n' +} +app=""; output=""; arch=""; identity=""; keychain=""; profile="${NOTARYTOOL_PROFILE:-}" +while [[ $# -gt 0 ]]; do + case "$1" in + --app|--output|--arch|--identity|--keychain|--keychain-profile) + [[ $# -ge 2 && -n "$2" ]] || { usage >&2; exit 2; } + case "$1" in + --app) app="$2";; --output) output="$2";; --arch) arch="$2";; + --identity) identity="$2";; --keychain) keychain="$2";; --keychain-profile) profile="$2";; + esac + shift 2 ;; + --help|-h) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac +done +[[ -n "$app" && "$output" == *.dmg && -n "$identity" && "$identity" != - ]] || { usage >&2; exit 2; } +case "$arch" in x64|arm64) ;; *) usage >&2; exit 2;; esac +[[ ! -e "$output" && ! -L "$output" ]] || { printf 'Refusing existing output\n' >&2; exit 1; } +[[ "$(uname -s)" == Darwin ]] || { printf 'Darwin required\n' >&2; exit 1; } +auth=() +if [[ -n "$profile" ]]; then + auth=(--keychain-profile "$profile") +else + [[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]] || { + printf 'Notarization credentials required\n' >&2; exit 2; + } + auth=(--apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD") +fi +root="$(cd "$(dirname "$0")/.." && pwd)" +work="$(mktemp -d "${TMPDIR:-/tmp}/acecode-installer-release.XXXXXX")" +trap 'rm -rf -- "$work"' EXIT +bash "$root/packaging/macos-installer/build.sh" --app "$app" --output "$work/image" --arch "$arch" +installer="$work/image/ACECode Installer.app" +sign_args=(--sign "$identity" --timestamp) +if [[ -n "$keychain" ]]; then sign_args+=(--keychain "$keychain"); fi +# Deliberately NOT --deep: only sign the wrapper, never the stapled payload. +/usr/bin/codesign --force --options runtime "${sign_args[@]}" "$installer" +/usr/bin/codesign --verify --deep --strict "$installer" +details="$(/usr/bin/codesign -dv --verbose=4 "$installer" 2>&1)" +grep -Fxq 'TeamIdentifier=T52GZCH73Y' <<< "$details" +grep -Fq 'Authority=Developer ID Application:' <<< "$details" +/usr/bin/diff -rq "$app" "$installer/Contents/Resources/ACECode.app" +notarize() { + local input="$1" ticket="$2" + xcrun notarytool submit "$input" "${auth[@]}" --wait --output-format json > "$work/result.json" + local status submission + status="$(/usr/bin/plutil -extract status raw -o - "$work/result.json")" + if [[ "$status" != Accepted ]]; then + submission="$(/usr/bin/plutil -extract id raw -o - "$work/result.json" 2>/dev/null || true)" + if [[ -n "$submission" ]]; then xcrun notarytool log "$submission" "${auth[@]}" || true; fi + printf 'Notarization rejected: %s\n' "$status" >&2; exit 1 + fi + xcrun stapler staple "$ticket" + xcrun stapler validate "$ticket" +} +/usr/bin/ditto -c -k --keepParent "$installer" "$work/installer.zip" +notarize "$work/installer.zip" "$installer" +/usr/bin/codesign --verify --deep --strict "$installer" +/usr/sbin/spctl --assess --type execute --verbose=4 "$installer" +/usr/bin/diff -rq "$app" "$installer/Contents/Resources/ACECode.app" +xcrun stapler validate "$installer/Contents/Resources/ACECode.app" +# The image root contains only the installer, not a misleading Applications link. +/usr/bin/hdiutil create -volname 'ACECode Installer' -srcfolder "$work/image" -format UDZO "$work/release.dmg" +/usr/bin/codesign "${sign_args[@]}" "$work/release.dmg" +notarize "$work/release.dmg" "$work/release.dmg" +/usr/bin/codesign --verify --strict "$work/release.dmg" +/usr/sbin/spctl --assess --type open --context context:primary-signature --verbose=4 "$work/release.dmg" +mkdir -p "$(dirname "$output")" +/usr/bin/ditto "$work/release.dmg" "$output" +printf 'Signed, notarized and stapled installer DMG: %s\n' "$output" diff --git a/scripts/select_macos_update_assets.py b/scripts/select_macos_update_assets.py new file mode 100644 index 000000000..877facc17 --- /dev/null +++ b/scripts/select_macos_update_assets.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Select only exact, trusted macOS updater ZIP names, never installer assets.""" +import argparse +from pathlib import Path + + +def select_assets(directory, version): + if not version or any(c not in '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-+' for c in version): + raise ValueError('Invalid release version') + selected = [] + for arch in ('x64', 'arm64'): + name = f'ACECode-{version}-macos-{arch}-update.zip' + matches = [p for p in Path(directory).rglob(name) if p.is_file() and not p.is_symlink()] + if len(matches) != 1: + raise ValueError(f'Expected exactly one updater asset {name}; found {len(matches)}') + selected.append(matches[0]) + return selected + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('directory', type=Path) + parser.add_argument('version') + args = parser.parse_args() + try: + for asset in select_assets(args.directory, args.version): + print(asset) + except ValueError as error: + parser.exit(1, f'{error}\n') diff --git a/src/agent_loop.cpp b/src/agent_loop.cpp index 5f8cb2e5d..8252bcd41 100644 --- a/src/agent_loop.cpp +++ b/src/agent_loop.cpp @@ -355,6 +355,20 @@ bool is_hidden_goal_context_message(const ChatMessage& msg) { msg.metadata.value("hidden_goal_context", false); } +const ChatMessage* trailing_transcript_message( + const std::vector& messages) { + for (auto it = messages.rbegin(); it != messages.rend(); ++it) { + // Match the transcript's invisible bookkeeping records. Never skip a + // visible assistant/tool/system/error, even when its content is empty. + if (it->is_meta || is_file_checkpoint_message(*it) || + is_compact_checkpoint_message(*it) || + is_turn_timing_message(*it) || is_turn_net_diff_message(*it) || + web::is_hidden_goal_context_message(*it)) continue; + return &*it; + } + return nullptr; +} + std::string escape_xml_text(const std::string& input) { std::string out; out.reserve(input.size()); @@ -477,7 +491,37 @@ void AgentLoop::set_session_manager(SessionManager* sm) { events_.set_observer({}); return; } - events_.set_observer([sm](const SessionEvent& event) { + events_.set_observer([this, sm](const SessionEvent& event) { + switch (event.kind) { + case SessionEventKind::Message: { + const auto& payload = event.payload; + const auto metadata = payload.value("metadata", nlohmann::json::object()); + if (!payload.value("is_meta", false) && + !(metadata.is_object() && metadata.value("hidden_goal_context", false))) { + live_transcript_tail_blocked_ = payload.value("role", std::string{}) != "user"; + } + break; + } + case SessionEventKind::Token: + case SessionEventKind::Reasoning: + if (!event.payload.value("text", std::string{}).empty()) { + live_transcript_tail_blocked_ = true; + } + break; + case SessionEventKind::ToolStart: + case SessionEventKind::ToolUpdate: + case SessionEventKind::ToolEnd: + case SessionEventKind::Error: + live_transcript_tail_blocked_ = true; + break; + case SessionEventKind::TranscriptReplace: + // A full replacement discards transient output. The canonical + // histories are still checked before any retry is accepted. + live_transcript_tail_blocked_ = false; + break; + default: + break; + } if (!should_persist_trajectory_event(event)) return; sm->record_trajectory_event( to_string(event.kind), event.payload, event.timestamp_ms); @@ -975,6 +1019,21 @@ void AgentLoop::worker_main() { try { switch (task.kind) { case WorkerTask::Kind::Chat: + if (!task.retry_user_message_id.empty()) { + const auto message = retryable_user_message(task.retry_user_message_id); + if (!message) { + throw std::runtime_error("transcript no longer ends with the expected user message"); + } + UserInput input; + input.text = message->content; + input.content_parts = message->content_parts; + input.metadata = message->metadata; + if (message->metadata.is_object()) { + input.display_text = message->metadata.value("display_text", std::string{}); + } + run_agent_with_input(input, false, &*message); + break; + } if (task.input.empty() && !task.payload.empty()) { task.input.text = std::move(task.payload); task.input.display_text = std::move(task.display_text); @@ -1217,6 +1276,45 @@ void AgentLoop::submit(const UserInput& input) { queue_cv_.notify_one(); } +std::optional AgentLoop::retryable_user_message( + const std::string& expected_user_message_id) const { + if (expected_user_message_id.empty() || live_transcript_tail_blocked_.load()) return std::nullopt; + const auto* message = trailing_transcript_message(messages_); + if (!message || message->role != "user" || + message->uuid != expected_user_message_id) return std::nullopt; + if (session_manager_) { + const auto persisted = session_manager_->load_active_messages(); + const auto* tail = trailing_transcript_message(persisted); + if (!tail || tail->role != "user" || + tail->uuid != expected_user_message_id) return std::nullopt; + } + return *message; +} + +bool AgentLoop::retry_last_user_message( + const std::string& expected_user_message_id, std::string& error) { + { + std::lock_guard lock(queue_mu_); + if (shutdown_requested_ || worker_task_active_ || busy_.load() || + !priority_task_queue_.empty() || !task_queue_.empty()) { + error = "session has active or queued work"; + return false; + } + if (!retryable_user_message(expected_user_message_id)) { + error = "transcript does not end with the expected user message"; + return false; + } + WorkerTask task; + task.kind = WorkerTask::Kind::Chat; + task.retry_user_message_id = expected_user_message_id; + task_queue_.push(std::move(task)); + abort_requested_ = false; + } + error.clear(); + queue_cv_.notify_one(); + return true; +} + ControlEnqueueReceipt AgentLoop::enqueue_control( std::function control) { ControlEnqueueReceipt receipt; @@ -2684,14 +2782,6 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input, messages_.push_back(user_msg); if (session_manager_) { session_manager_->on_message(user_msg); - if (info.visible_timed_turn) { - session_manager_->record_trajectory_event( - "turn_start", - {{"turn_id", info.active_turn_id}, - {"user_message_id", user_msg.uuid}, - {"started_at_ms", info.turn_started_at_ms}}, - info.turn_started_at_ms); - } if (!hidden_goal_context) { session_manager_->begin_user_turn_checkpoint(user_msg.uuid); } @@ -2711,7 +2801,33 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input, events_.emit(SessionEventKind::Message, msg_event); } + start_user_turn(info); + return info; +} + +AgentLoop::UserTurnInfo AgentLoop::prepare_retry_user_turn(const ChatMessage& message) { + UserTurnInfo info; + info.user_msg = message; + info.turn_started_at_ms = now_epoch_ms(); + info.active_turn_id = message.uuid; + info.visible_timed_turn = true; + info.turn_user_uuid = message.uuid; + // Preserve the original checkpoint and message. Re-expansion or a second + // on_message call would change the input or create adjacent user records. + start_user_turn(info); + return info; +} + +void AgentLoop::start_user_turn(const UserTurnInfo& info) { if (session_manager_) { + if (info.visible_timed_turn) { + session_manager_->record_trajectory_event( + "turn_start", + {{"turn_id", info.active_turn_id}, + {"user_message_id", info.turn_user_uuid}, + {"started_at_ms", info.turn_started_at_ms}}, + info.turn_started_at_ms); + } session_manager_->record_trajectory_event( "busy_changed", {{"busy", true}, {"turn_id", info.active_turn_id}}); @@ -2724,8 +2840,6 @@ AgentLoop::UserTurnInfo AgentLoop::prepare_user_turn(const UserInput& input, {"busy", true}, {"turn_id", info.active_turn_id}, }); - - return info; } AgentLoop::ApiRequestBundle AgentLoop::build_api_request_messages( @@ -5211,7 +5325,8 @@ bool AgentLoop::execute_tool_calls( } void AgentLoop::run_agent_with_input(const UserInput& input, - bool hidden_goal_context) { + bool hidden_goal_context, + const ChatMessage* retry_message) { { std::lock_guard lock(sandbox_prompt_mutex_); sandbox_prompt_snapshot_.reset(); @@ -5277,12 +5392,14 @@ void AgentLoop::run_agent_with_input(const UserInput& input, // already-recorded history. Persisting first would put the new request into // the summary and append the checkpoint after the input, breaking replay. bool preturn_compaction_failed = false; - if (active_estimate_exceeds_auto_threshold(&input)) { + if (!retry_message && active_estimate_exceeds_auto_threshold(&input)) { preturn_compaction_failed = !maybe_run_auto_compact(); } // Phase 1: Build and persist user message after the pre-turn compact attempt. - auto turn_info = prepare_user_turn(input, hidden_goal_context); + auto turn_info = retry_message + ? prepare_retry_user_turn(*retry_message) + : prepare_user_turn(input, hidden_goal_context); std::string turn_timing_status = "completed"; if (preturn_compaction_failed) { turn_timing_status = "error"; diff --git a/src/agent_loop.hpp b/src/agent_loop.hpp index fc8519f67..5110b05f9 100644 --- a/src/agent_loop.hpp +++ b/src/agent_loop.hpp @@ -206,6 +206,11 @@ class AgentLoop { // context parts. Existing text-only submit overloads delegate here. void submit(const UserInput& input); + // Retry only the exact trailing user message while the worker and queue + // are idle. Reuses its stored input instead of appending another user. + bool retry_last_user_message(const std::string& expected_user_message_id, + std::string& error); + // Submit a user-initiated shell command triggered by `!` mode. Non-blocking: // enqueues on the same worker so it serialises with LLM turns. The worker // invokes BashTool directly (no LLM round-trip), emits tool_call + tool_result @@ -299,6 +304,7 @@ class AgentLoop { // Clear all messages (for /clear command) void clear_messages() { messages_.clear(); + live_transcript_tail_blocked_ = false; last_api_total_tokens_.store(0, std::memory_order_relaxed); compact_window_initialized_ = false; compact_window_number_ = 0; @@ -506,7 +512,10 @@ class AgentLoop { void join_side_question_threads(); void run_agent(const std::string& user_message); void run_agent_with_input(const UserInput& input, - bool hidden_goal_context = false); + bool hidden_goal_context = false, + const ChatMessage* retry_message = nullptr); + std::optional retryable_user_message( + const std::string& expected_user_message_id) const; // Variant that records `display_text` into the user message's metadata.display_text // so UI can show the original input while the LLM sees an expanded `prompt`. // When `display_text` is empty, behaves identically to run_agent(prompt). @@ -634,6 +643,8 @@ class AgentLoop { std::int64_t turn_started_at_ms = 0; }; UserTurnInfo prepare_user_turn(const UserInput& input, bool hidden_goal_context); + UserTurnInfo prepare_retry_user_turn(const ChatMessage& message); + void start_user_turn(const UserTurnInfo& info); // Phase 2: Build the full message list for the LLM provider. struct ApiRequestBundle { @@ -738,12 +749,16 @@ class AgentLoop { std::string display_text; bool hidden_goal_context = false; std::function control; + std::string retry_user_message_id; }; ProviderAccessor provider_accessor_; ToolExecutor& tools_; AgentCallbacks callbacks_; std::vector messages_; + // Visible events (notably errors and partial output) may not be present + // in model history or JSONL. They must also invalidate an empty retry. + std::atomic live_transcript_tail_blocked_{false}; mutable std::mutex side_question_context_mu_; std::vector side_question_context_; std::mutex side_question_threads_mu_; diff --git a/src/desktop/desktop_restart.cpp b/src/desktop/desktop_restart.cpp index 654689506..e86275755 100644 --- a/src/desktop/desktop_restart.cpp +++ b/src/desktop/desktop_restart.cpp @@ -47,7 +47,10 @@ fs::path current_desktop_executable_path() { buffer.assign(static_cast(size) + 1, '\0'); if (_NSGetExecutablePath(buffer.data(), &size) != 0) return {}; } - return fs::path(buffer.data()); + // Capture the stable installation path before an update renames the bundle. + std::error_code ec; + const fs::path resolved = fs::canonical(fs::path(buffer.data()), ec); + return ec ? fs::path{} : resolved; #else std::vector buffer(4096); for (;;) { diff --git a/src/session/global_session_catalog.cpp b/src/session/global_session_catalog.cpp index 6ca6097d1..8ac442e03 100644 --- a/src/session/global_session_catalog.cpp +++ b/src/session/global_session_catalog.cpp @@ -219,7 +219,10 @@ ProjectScanResult scan_project(const fs::path& project_path, if (meta.archived) { result.archived_identities.push_back(identity); } - if (meta.archived || !meta.parent_session_id.empty()) continue; + if ((!options.include_archived && meta.archived) || + (!options.include_subagents && !meta.parent_session_id.empty())) { + continue; + } GlobalSessionCatalogEntry entry; entry.project_dir = project_dir; @@ -273,7 +276,9 @@ GlobalSessionCatalog merge_project_results( const fs::path& root, std::vector project_results, const std::vector& active_sessions, - bool prefer_content) { + bool prefer_content, + bool include_archived = false, + bool include_subagents = false) { GlobalSessionCatalog catalog; std::unordered_set archived_identities; std::unordered_map index_by_identity; @@ -304,7 +309,8 @@ GlobalSessionCatalog merge_project_results( } for (const auto& active : active_sessions) { - if (active.id.empty() || !active.parent_session_id.empty()) continue; + if (active.id.empty() || + (!include_subagents && !active.parent_session_id.empty())) continue; const std::string workspace_hash = active.no_workspace ? std::string{} : (!active.workspace_hash.empty() @@ -312,7 +318,7 @@ GlobalSessionCatalog merge_project_results( : compute_cwd_hash(active.cwd)); const std::string identity = global_session_catalog_identity( active.no_workspace, workspace_hash, active.id); - if (archived_identities.count(identity)) continue; + if (!include_archived && archived_identities.count(identity)) continue; auto found = index_by_identity.find(identity); if (found != index_by_identity.end()) { @@ -407,7 +413,8 @@ GlobalSessionCatalog build_global_session_catalog( const bool prefer_content = options.content_query.has_value() && !options.content_query->empty(); auto catalog = merge_project_results( - root, std::move(project_results), active_sessions, prefer_content); + root, std::move(project_results), active_sessions, prefer_content, + options.include_archived, options.include_subagents); for (auto& error : discovery.errors) { catalog.errors.push_back(std::move(error)); } diff --git a/src/session/global_session_catalog.hpp b/src/session/global_session_catalog.hpp index 23ab8324b..06e464751 100644 --- a/src/session/global_session_catalog.hpp +++ b/src/session/global_session_catalog.hpp @@ -14,7 +14,7 @@ namespace acecode { -// One globally discoverable top-level session. Project/workspace data is +// One globally discoverable session. Project/workspace data is // descriptive metadata only: workspace visibility never decides whether the // entry is present. struct GlobalSessionCatalogEntry { @@ -37,6 +37,11 @@ struct GlobalSessionCatalogError { }; struct GlobalSessionCatalogOptions { + // Model-facing discovery can explicitly include archived/child sessions. + // UI discovery retains its existing top-level, non-archived defaults. + bool include_archived = false; + bool include_subagents = false; + // Absent or empty means metadata-only discovery. A non-empty query reuses // each project's existing user-message index and annotates matching rows. std::optional content_query; diff --git a/src/session/local_session_client.cpp b/src/session/local_session_client.cpp index 76ab412be..3bcf6a84d 100644 --- a/src/session/local_session_client.cpp +++ b/src/session/local_session_client.cpp @@ -78,6 +78,23 @@ bool LocalSessionClient::send_input(const std::string& session_id, const UserInp return true; } +bool LocalSessionClient::retry_last_user_message( + const std::string& session_id, + const std::string& expected_user_message_id, + std::string& error) { + std::shared_lock migration_lock(environment::data_dir_write_mutex()); + if (environment::data_dir_writes_blocked()) { + error = "data directory migration is in progress"; + return false; + } + auto entry = registry_.acquire(session_id); + if (!entry || !entry->loop) { + error = "unknown session"; + return false; + } + return entry->loop->retry_last_user_message(expected_user_message_id, error); +} + TurnSteerResult LocalSessionClient::steer_input( const std::string& session_id, const std::string& expected_turn_id, diff --git a/src/session/local_session_client.hpp b/src/session/local_session_client.hpp index e4d230116..8a4431b41 100644 --- a/src/session/local_session_client.hpp +++ b/src/session/local_session_client.hpp @@ -31,6 +31,9 @@ class LocalSessionClient : public SessionClient { const std::string& text, const std::string& display_text) override; bool send_input(const std::string& session_id, const UserInput& input) override; + bool retry_last_user_message(const std::string& session_id, + const std::string& expected_user_message_id, + std::string& error) override; TurnSteerResult steer_input(const std::string& session_id, const std::string& expected_turn_id, const UserInput& input) override; diff --git a/src/session/session_client.hpp b/src/session/session_client.hpp index e53043b94..4563a84c3 100644 --- a/src/session/session_client.hpp +++ b/src/session/session_client.hpp @@ -354,6 +354,17 @@ class SessionClient { return send_input(session_id, input.text, input.display_text); } + // Reuse the exact trailing user message without appending new input. + // Implementations must check the transcript and worker queue atomically. + virtual bool retry_last_user_message(const std::string& session_id, + const std::string& expected_user_message_id, + std::string& error) { + (void)session_id; + (void)expected_user_message_id; + error = "retrying the last user message is unavailable"; + return false; + } + // Append input to the currently running regular turn. Implementations must // validate expected_turn_id atomically with enqueueing so callers cannot // accidentally steer a replacement turn. diff --git a/src/session/thread_service.cpp b/src/session/thread_service.cpp index 43690c42d..0c2716757 100644 --- a/src/session/thread_service.cpp +++ b/src/session/thread_service.cpp @@ -1,6 +1,7 @@ #include "thread_service.hpp" #include "compact_checkpoint.hpp" +#include "global_session_catalog.hpp" #include "session_manager.hpp" #include "session_pin_store.hpp" #include "session_registry.hpp" @@ -8,6 +9,7 @@ #include "session_user_message_search.hpp" #include "thread_repair.hpp" #include "../commands/compact.hpp" +#include "../config/config.hpp" #include "../utils/encoding.hpp" #include "../utils/logger.hpp" #include "../utils/utf8_path.hpp" @@ -16,9 +18,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -157,6 +161,131 @@ std::optional resolve_thread( return scoped; } +std::string projects_directory() { + return path_to_utf8(path_from_utf8(get_acecode_dir()) / "projects"); +} + +std::string storage_workspace_hash(const std::string& project_dir) { + return path_to_utf8(path_from_utf8(project_dir).filename()); +} + +bool valid_storage_token(const std::string& token) { + return !token.empty() && std::all_of(token.begin(), token.end(), + [](unsigned char ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || ch == '-' || ch == '_'; + }); +} + +// Read-only lookup. Exact metadata paths avoid loading every session's history +// just to resolve one id. workspaceHash also disambiguates imported ids. +std::optional resolve_discovered_thread( + const ThreadService::Deps& deps, + const ThreadScope& scope, + const std::string& requested_id, + const std::string& workspace_hash, + std::string& error) { + const std::string id = effective_thread_id(scope, requested_id); + if (!valid_storage_token(id) || + (!workspace_hash.empty() && !valid_storage_token(workspace_hash))) { + error = "invalid threadId or workspaceHash"; + return std::nullopt; + } + + std::optional result; + auto retain = [&](ScopedThread candidate) { + if (!workspace_hash.empty() && + storage_workspace_hash(candidate.project_dir) != workspace_hash) { + return true; + } + if (result && storage_workspace_hash(result->project_dir) != + storage_workspace_hash(candidate.project_dir)) { + error = "threadId matches multiple ACECode workspaces; pass " + "workspaceHash from list_threads"; + return false; + } + if (!result || candidate.active || candidate.caller_manager) { + result = std::move(candidate); + } + return true; + }; + auto read_project = [&](const std::string& project_dir) { + ScopedThread candidate; + candidate.project_dir = project_dir; + candidate.meta = SessionStorage::read_meta( + SessionStorage::meta_path(project_dir, id)); + return candidate.meta.id != id || retain(std::move(candidate)); + }; + + const auto root = path_from_utf8(projects_directory()); + if (!workspace_hash.empty()) { + if (!read_project(path_to_utf8(root / workspace_hash))) return std::nullopt; + } else { + std::error_code ec; + std::filesystem::directory_iterator it(root, ec), end; + if (ec == std::errc::no_such_file_or_directory) ec.clear(); + for (; !ec && it != end; it.increment(ec)) { + std::error_code item_ec; + if (it->is_directory(item_ec) && + !read_project(path_to_utf8(it->path()))) return std::nullopt; + if (item_ec) { ec = item_ec; break; } + } + if (ec) { + error = "failed to discover ACECode thread: " + ec.message(); + return std::nullopt; + } + } + + if (deps.registry) { + if (auto active = deps.registry->acquire(id)) { + ScopedThread candidate; + candidate.project_dir = active->sm + ? active->sm->current_project_dir() : std::string{}; + if (candidate.project_dir.empty()) { + candidate.project_dir = path_to_utf8(root / + (active->workspace_hash.empty() + ? SessionStorage::compute_project_hash(active->cwd) + : active->workspace_hash)); + } + candidate.meta = active->sm + ? active->sm->load_session_meta(id) : SessionMeta{}; + if (candidate.meta.id.empty()) candidate.meta = synthesize_meta(*active); + candidate.active = std::move(active); + if (!retain(std::move(candidate))) return std::nullopt; + } + } + if (id == scope.caller_thread_id && scope.caller_manager) { + ScopedThread candidate; + candidate.project_dir = scope.caller_manager->current_project_dir(); + if (candidate.project_dir.empty() && !scope.cwd.empty()) { + candidate.project_dir = SessionStorage::get_project_dir(scope.cwd); + } + if (!candidate.project_dir.empty()) { + candidate.meta = scope.caller_manager->load_session_meta(id); + if (candidate.meta.id.empty()) { + candidate.meta.id = id; + candidate.meta.cwd = scope.cwd; + candidate.meta.title = scope.caller_manager->current_title(); + } + candidate.caller_manager = scope.caller_manager; + candidate.is_caller = true; + if (result && storage_workspace_hash(result->project_dir) == + storage_workspace_hash(candidate.project_dir)) { + candidate.active = result->active; + } + if (!retain(std::move(candidate))) return std::nullopt; + } + } + if (!result) { + error = "thread not found in ACECode sessions"; + } else if (id == scope.caller_thread_id && !scope.cwd.empty() && + storage_workspace_hash(result->project_dir) == + SessionStorage::compute_project_hash(scope.cwd)) { + result->is_caller = true; + } + return result; +} + std::vector load_thread_messages(const ScopedThread& scoped) { if (auto* manager = scoped.manager()) { auto messages = manager->load_active_messages(); @@ -185,6 +314,9 @@ bool hidden_message(const ChatMessage& message) { std::optional parse_cursor_offset(const std::string& cursor) { if (cursor.empty()) return std::size_t{0}; + if (!std::all_of(cursor.begin(), cursor.end(), [](unsigned char ch) { + return ch >= '0' && ch <= '9'; + })) return std::nullopt; try { std::size_t consumed = 0; const auto value = std::stoull(cursor, &consumed, 10); @@ -206,6 +338,8 @@ json thread_summary(const SessionMeta& meta, const bool busy = active && active->busy; json out{ {"threadId", meta.id}, + {"cwd", meta.cwd}, + {"noWorkspace", meta.no_workspace}, {"title", active && !active->title.empty() ? active->title : meta.title}, {"summary", trim_and_limit( active && !active->summary.empty() ? active->summary : meta.summary, @@ -293,98 +427,81 @@ ThreadServiceResult ThreadServiceResult::fail(std::string error) { ThreadService::ThreadService(Deps deps) : deps_(deps) {} ThreadServiceResult ThreadService::list(const ThreadScope& scope, - std::size_t limit) const { - if (scope.cwd.empty()) { - return ThreadServiceResult::fail("thread workspace is unavailable"); - } + std::size_t limit, + const std::string& cursor, + bool include_archived) const { + const auto offset = parse_cursor_offset(cursor); + if (!offset) return ThreadServiceResult::fail("invalid thread cursor"); limit = (std::max)(std::size_t{1}, (std::min)(limit, kMaxListLimit)); - const std::string project_dir = - SessionStorage::get_project_dir(scope.cwd); - - std::unordered_map metas; - for (auto meta : SessionStorage::list_sessions(project_dir)) { - if (!meta.archived) metas.emplace(meta.id, std::move(meta)); - } - - std::unordered_map active_infos; - if (deps_.registry) { - for (auto info : deps_.registry->list_active()) { - const bool matches = info.cwd == scope.cwd || - info.workspace_hash == - SessionStorage::compute_project_hash(scope.cwd); - if (!matches) continue; - auto found = metas.find(info.id); - if (found == metas.end()) { - SessionMeta meta = SessionStorage::read_meta( - SessionStorage::meta_path(project_dir, info.id)); - if (!meta.id.empty() && meta.archived) continue; - if (meta.id.empty()) { - meta.id = info.id; - meta.cwd = info.cwd; - meta.created_at = info.created_at; - meta.updated_at = info.updated_at; - meta.summary = info.summary; - meta.title = info.title; - meta.parent_session_id = info.parent_session_id; - } - metas.emplace(meta.id, std::move(meta)); - } - active_infos.emplace(info.id, std::move(info)); + GlobalSessionCatalogOptions options; + options.include_archived = include_archived; + options.include_subagents = true; + const auto catalog = build_global_session_catalog( + projects_directory(), deps_.registry + ? deps_.registry->list_active() : std::vector{}, options); + + auto summary = [&](const GlobalSessionCatalogEntry& entry, bool pinned, + int pinned_index = 0) { + auto out = thread_summary(entry.meta, + entry.active ? &*entry.active : nullptr, pinned, pinned_index); + out["workspaceHash"] = storage_workspace_hash(entry.project_dir); + out["workspaceName"] = entry.workspace_name; + out["workspaceVisible"] = entry.workspace_visible; + if (scope.caller_manager && entry.meta.id == scope.caller_thread_id && + entry.project_dir == scope.caller_manager->current_project_dir()) { + out["active"] = true; } - } - - struct Row { - SessionMeta meta; - const SessionInfo* active = nullptr; + return out; }; - std::unordered_map rows_by_id; - for (auto& [id, meta] : metas) { - auto active = active_infos.find(id); - rows_by_id.emplace(id, Row{ - meta, active == active_infos.end() ? nullptr : &active->second}); - } - const auto pin_path = path_from_utf8(project_dir) / - "pinned_sessions.json"; - const auto pins = session_pins::read_pinned_sessions_state(pin_path); - std::unordered_set pinned_ids; + // The project directory is part of identity: imported ids may coincide + // across workspaces, and each project owns its own pin order. + std::map> + projects; + for (const auto& entry : catalog.entries) { + projects[entry.project_dir].emplace(entry.meta.id, &entry); + } + std::unordered_set pinned_entries; json pinned_threads = json::array(); int pinned_index = 0; - for (const auto& id : pins.session_ids) { - auto found = rows_by_id.find(id); - if (found == rows_by_id.end()) continue; - pinned_ids.insert(id); - pinned_threads.push_back(thread_summary( - found->second.meta, found->second.active, - true, ++pinned_index)); - } - - std::vector regular; - regular.reserve(rows_by_id.size()); - for (const auto& [id, row] : rows_by_id) { - if (!pinned_ids.count(id)) regular.push_back(&row); - } - std::sort(regular.begin(), regular.end(), [](const Row* lhs, const Row* rhs) { - const std::string lhs_updated = lhs->active && - !lhs->active->updated_at.empty() - ? lhs->active->updated_at : lhs->meta.updated_at; - const std::string rhs_updated = rhs->active && - !rhs->active->updated_at.empty() - ? rhs->active->updated_at : rhs->meta.updated_at; - if (lhs_updated != rhs_updated) return lhs_updated > rhs_updated; - return lhs->meta.id < rhs->meta.id; - }); + for (const auto& [project_dir, entries] : projects) { + const auto pins = session_pins::read_pinned_sessions_state( + path_from_utf8(project_dir) / "pinned_sessions.json"); + for (const auto& id : pins.session_ids) { + const auto found = entries.find(id); + if (found == entries.end() || found->second->meta.archived || + !pinned_entries.insert(found->second).second) continue; + pinned_threads.push_back(summary(*found->second, true, ++pinned_index)); + } + } + std::vector regular; + for (const auto& entry : catalog.entries) { + if (!pinned_entries.count(&entry)) regular.push_back(&entry); + } + const std::size_t start = (std::min)(*offset, regular.size()); + const std::size_t count = (std::min)(limit, regular.size() - start); + const std::size_t next = start + count; + const bool has_more = next < regular.size(); json threads = json::array(); - for (std::size_t i = 0; i < regular.size() && i < limit; ++i) { - threads.push_back(thread_summary( - regular[i]->meta, regular[i]->active, false)); + for (std::size_t i = start; i < next; ++i) { + threads.push_back(summary(*regular[i], false)); + } + json errors = json::array(); + for (const auto& error : catalog.errors) { + errors.push_back(json{ + {"workspaceHash", error.workspace_hash}, + {"stage", error.stage}, {"message", error.message}, + }); } return ThreadServiceResult::ok(json{ {"pinnedThreads", std::move(pinned_threads)}, {"threads", std::move(threads)}, - {"hasMore", regular.size() > limit}, + {"hasMore", has_more}, + {"nextCursor", has_more ? json(std::to_string(next)) : json(nullptr)}, + {"errors", std::move(errors)}, }); } @@ -394,12 +511,12 @@ ThreadServiceResult ThreadService::read( const std::string& cursor, std::size_t turn_limit, bool include_outputs, - std::size_t max_chars_per_item) const { - auto scoped = resolve_thread(deps_, scope, requested_id); - if (!scoped) { - return ThreadServiceResult::fail( - "thread is unavailable in the current workspace"); - } + std::size_t max_chars_per_item, + const std::string& workspace_hash) const { + std::string error; + auto scoped = resolve_discovered_thread( + deps_, scope, requested_id, workspace_hash, error); + if (!scoped) return ThreadServiceResult::fail(std::move(error)); const auto offset = parse_cursor_offset(cursor); if (!offset) return ThreadServiceResult::fail("invalid thread cursor"); turn_limit = (std::max)(std::size_t{1}, @@ -464,6 +581,9 @@ ThreadServiceResult ThreadService::read( } return ThreadServiceResult::ok(json{ {"threadId", scoped->meta.id}, + {"workspaceHash", storage_workspace_hash(scoped->project_dir)}, + {"cwd", scoped->meta.cwd}, + {"noWorkspace", scoped->meta.no_workspace}, {"title", scoped->manager() ? scoped->manager()->current_title() : scoped->meta.title}, @@ -504,30 +624,35 @@ ThreadServiceResult ThreadService::wait( json errors = json::array(); std::unordered_set seen; for (const auto& target : targets) { - if (target.thread_id.empty() || !seen.insert(target.thread_id).second) { + std::string error; + auto scoped = resolve_discovered_thread( + deps_, scope, target.thread_id, target.workspace_hash, error); + if (target.thread_id.empty() || !scoped) { errors.push_back(json{ {"threadId", target.thread_id}, - {"error", "empty or duplicate threadId"}, + {"workspaceHash", target.workspace_hash}, + {"error", target.thread_id.empty() ? "threadId is required" : error}, }); continue; } - if (target.thread_id == scope.caller_thread_id) { + if (scoped->is_caller) { errors.push_back(json{ {"threadId", target.thread_id}, {"error", "the calling thread cannot wait for itself"}, }); continue; } - auto scoped = resolve_thread(deps_, scope, target.thread_id); - if (!scoped) { + const auto hash = storage_workspace_hash(scoped->project_dir); + if (!seen.insert(hash + ':' + target.thread_id).second) { errors.push_back(json{ {"threadId", target.thread_id}, - {"error", "thread not found"}, + {"error", "duplicate thread target"}, }); continue; } State state; state.target = target; + state.target.workspace_hash = hash; state.cursor = target.after_cursor; state.active = scoped->active; state.terminal = !state.active || !state.active->loop || @@ -618,6 +743,7 @@ ThreadServiceResult ThreadService::wait( } json snapshot{ {"threadId", state.target.thread_id}, + {"workspaceHash", state.target.workspace_hash}, {"cursor", std::to_string(state.cursor)}, {"active", active}, {"busy", busy}, diff --git a/src/session/thread_service.hpp b/src/session/thread_service.hpp index 6601de962..b514cb4eb 100644 --- a/src/session/thread_service.hpp +++ b/src/session/thread_service.hpp @@ -25,6 +25,7 @@ struct ThreadScope { struct ThreadWaitTarget { std::string thread_id; std::uint64_t after_cursor = 0; + std::string workspace_hash; }; struct ThreadServiceResult { @@ -42,8 +43,9 @@ struct ThreadServiceResult { }; // In-process thread domain service shared by model tools and host surfaces. -// It resolves persistent and active sessions in the calling workspace and -// never loops back through ACECode's HTTP API. +// Discovery reads persistent and active sessions across all ACECode projects. +// Mutations retain the calling workspace scope. No operation loops back +// through ACECode's HTTP API. class ThreadService { public: struct Deps { @@ -54,13 +56,16 @@ class ThreadService { explicit ThreadService(Deps deps); ThreadServiceResult list(const ThreadScope& scope, - std::size_t limit = 20) const; + std::size_t limit = 20, + const std::string& cursor = {}, + bool include_archived = false) const; ThreadServiceResult read(const ThreadScope& scope, const std::string& thread_id, const std::string& cursor = {}, std::size_t turn_limit = 8, bool include_outputs = false, - std::size_t max_chars_per_item = 2000) const; + std::size_t max_chars_per_item = 2000, + const std::string& workspace_hash = {}) const; ThreadServiceResult wait(const ThreadScope& scope, const std::vector& targets, int timeout_ms, diff --git a/src/tool/thread_tools.cpp b/src/tool/thread_tools.cpp index d9b2bb972..da92ff127 100644 --- a/src/tool/thread_tools.cpp +++ b/src/tool/thread_tools.cpp @@ -153,12 +153,16 @@ ToolImpl fork_thread_tool(std::shared_ptr deps) { ToolImpl list_threads_tool(std::shared_ptr deps) { return make_tool( "list_threads", - "List ACECode threads in the current workspace. pinnedThreads always " - "contains every pinned thread in UI order; limit applies only to " - "non-pinned threads.", + "List ACECode threads across all workspaces, including hidden and " + "workspace-free threads. Workspace is metadata, not a search boundary. " + "pinnedThreads contains every pinned thread in each workspace's pin " + "order; limit applies only to non-pinned threads. Pass nextCursor as " + "cursor for older threads, and includeArchived to also find archives.", object_schema(json{ {"limit", json{{"type", "integer"}, {"minimum", 1}, {"maximum", 50}}}, + {"cursor", json{{"type", "string"}}}, + {"includeArchived", json{{"type", "boolean"}}}, }), true, [deps](const std::string& arguments, @@ -171,17 +175,22 @@ ToolImpl list_threads_tool(std::shared_ptr deps) { const int limit = (std::max)(1, parser->get_or("limit", 20)); return service_result(service->list( - scope_from_context(ctx), static_cast(limit))); + scope_from_context(ctx), static_cast(limit), + parser->get_or("cursor", {}), + parser->get_or("includeArchived", false))); }); } ToolImpl read_thread_tool(std::shared_ptr deps) { return make_tool( "read_thread", - "Read recent bounded turn summaries for one ACECode thread without " - "opening it. Pass nextCursor to read older turns.", + "Read recent bounded turn summaries for an ACECode thread in any " + "workspace, including archived threads, without opening it. Pass " + "nextCursor as cursor to read older turns. workspaceHash from " + "list_threads locates the storage project and disambiguates threadId.", object_schema(json{ {"threadId", json{{"type", "string"}, {"minLength", 1}}}, + {"workspaceHash", json{{"type", "string"}}}, {"cursor", json{{"type", "string"}}}, {"turnLimit", json{{"type", "integer"}, {"minimum", 1}, {"maximum", 20}}}, @@ -208,7 +217,8 @@ ToolImpl read_thread_tool(std::shared_ptr deps) { parser->get_or("turnLimit", 8))), parser->get_or("includeOutputs", false), static_cast((std::max)(256, - parser->get_or("maxOutputCharsPerItem", 2000))))); + parser->get_or("maxOutputCharsPerItem", 2000))), + parser->get_or("workspaceHash", {}))); }); } @@ -243,8 +253,10 @@ ToolImpl send_message_tool(std::shared_ptr deps) { ToolImpl wait_threads_tool(std::shared_ptr deps) { return make_tool( "wait_threads", - "Wait for the first of up to eight ACECode threads to complete, fail, " - "or need attention. timeoutMs: 0 returns an immediate snapshot.", + "Wait for the first of up to eight ACECode threads across any " + "workspaces to complete, fail, or need attention. timeoutMs: 0 returns " + "an immediate snapshot. Each target may include workspaceHash from " + "list_threads to locate its storage project.", object_schema(json{ {"targets", json{ {"type", "array"}, {"minItems", 1}, {"maxItems", 8}, @@ -254,6 +266,7 @@ ToolImpl wait_threads_tool(std::shared_ptr deps) { {"threadId", json{{"type", "string"}, {"minLength", 1}}}, {"afterCursor", json{{"type", "string"}}}, + {"workspaceHash", json{{"type", "string"}}}, }}, {"required", json::array({"threadId"})}, {"additionalProperties", false}, @@ -286,6 +299,12 @@ ToolImpl wait_threads_tool(std::shared_ptr deps) { } ThreadWaitTarget target; target.thread_id = raw["threadId"].get(); + if (raw.contains("workspaceHash")) { + if (!raw["workspaceHash"].is_string()) { + return tool_error("workspaceHash must be a string"); + } + target.workspace_hash = raw["workspaceHash"].get(); + } if (raw.contains("afterCursor")) { if (!raw["afterCursor"].is_string()) { return tool_error("afterCursor must be a string"); diff --git a/src/upgrade/macos_app_installer.mm b/src/upgrade/macos_app_installer.mm index 37e17b982..bb208d4a2 100644 --- a/src/upgrade/macos_app_installer.mm +++ b/src/upgrade/macos_app_installer.mm @@ -4,7 +4,7 @@ #include "macos_app_installer.hpp" #include "diagnostics.hpp" -#include "desktop/user_install_policy.hpp" +#include "macos_bundle.hpp" #include #include @@ -16,6 +16,7 @@ #include #include +#include #include namespace fs = std::filesystem; @@ -24,7 +25,6 @@ namespace { constexpr const char* kBundleIdentifier = "dev.acecode.desktop"; -constexpr const char* kPreviousBundleName = ".ACECode.previous.app"; constexpr const char* kUpdateLockName = ".ACECode.update.lock"; void set_error(std::string* error, const std::string& message) { @@ -74,128 +74,14 @@ void set_error(std::string* error, const std::string& message) { return value ? fs::path(value) : fs::path{}; } -bool resource_flag(NSURL* url, - NSURLResourceKey key, - bool* value, - std::string* error) { - NSError* native_error = nil; - id resource_value = nil; - if (![url getResourceValue:&resource_value forKey:key error:&native_error]) { - set_error(error, ns_error_text(native_error)); - return false; - } - if (![resource_value isKindOfClass:[NSNumber class]]) { - set_error(error, "unexpected macOS filesystem metadata"); - return false; - } - *value = [(NSNumber*)resource_value boolValue] == YES; - return true; -} - bool validate_safe_install_path(const fs::path& installed_bundle, std::string* error) { - const char* home_bytes = [NSHomeDirectory() fileSystemRepresentation]; - if (!home_bytes) { - set_error(error, "current user's home directory is unavailable"); - return false; - } - const auto user_paths = - desktop::macos_user_install_paths(fs::path(home_bytes)); - const auto system_paths = desktop::macos_system_install_paths(); - const fs::path normalized_bundle = installed_bundle.lexically_normal(); - - fs::path applications_path; - desktop::MacosInstallLocation expected_location = - desktop::MacosInstallLocation::unsupported; - if (!user_paths.home.empty() && - normalized_bundle == user_paths.destination) { - applications_path = user_paths.applications; - expected_location = - desktop::MacosInstallLocation::user_applications; - } else if (normalized_bundle == system_paths.destination) { - applications_path = system_paths.applications; - expected_location = - desktop::MacosInstallLocation::system_applications; - } else { - set_error(error, - "macOS self-update requires ~/Applications/ACECode.app or " - "/Applications/ACECode.app; " - "reinstall ACECode with the signed PKG first"); - return false; - } - - NSURL* home_url = file_url(user_paths.home, YES); - NSURL* applications_url = file_url(applications_path, YES); - NSURL* installed_url = file_url(normalized_bundle, YES); - if (!home_url || !applications_url || !installed_url) { - set_error(error, "the macOS installation path cannot be represented safely"); - return false; - } - - NSFileManager* file_manager = [NSFileManager defaultManager]; - BOOL is_directory = NO; - if (![file_manager fileExistsAtPath:[applications_url path] - isDirectory:&is_directory] || is_directory == NO) { - set_error(error, applications_path.string() + - " is missing or is not a directory"); - return false; - } - bool is_symlink = false; - if (!resource_flag(applications_url, NSURLIsSymbolicLinkKey, - &is_symlink, error) || is_symlink) { - if (is_symlink) { - set_error(error, applications_path.string() + - " must not be a symbolic link"); - } - return false; - } - - is_directory = NO; - if (![file_manager fileExistsAtPath:[installed_url path] - isDirectory:&is_directory] || is_directory == NO) { - set_error(error, "the installed ACECode.app bundle is missing"); - return false; - } - is_symlink = false; - if (!resource_flag(installed_url, NSURLIsSymbolicLinkKey, - &is_symlink, error) || is_symlink) { - if (is_symlink) { - set_error(error, "the installed ACECode.app must not be a symbolic link"); - } - return false; - } - - NSURL* resolved_home = [home_url URLByResolvingSymlinksInPath]; - NSURL* resolved_applications = [applications_url URLByResolvingSymlinksInPath]; - NSURL* resolved_destination = - [resolved_applications URLByAppendingPathComponent:@"ACECode.app" - isDirectory:YES]; - const auto resolved_location = - desktop::macos_self_update_install_location( - filesystem_path(resolved_home), - filesystem_path(resolved_applications), - filesystem_path(resolved_destination)); - if (resolved_location != expected_location || - filesystem_path([installed_url URLByResolvingSymlinksInPath]) != - filesystem_path(resolved_destination)) { - set_error(error, - "the resolved ACECode.app path is outside the supported installation locations"); - return false; - } - - const fs::path resolved_applications_path = - filesystem_path(resolved_applications); - if (::access(resolved_applications_path.c_str(), W_OK | X_OK) != 0) { - if (expected_location == - desktop::MacosInstallLocation::system_applications) { - set_error(error, - "ACECode cannot modify /Applications. Install the update " - "manually with the signed ACECode PKG"); - } else { - set_error(error, - "ACECode cannot modify ~/Applications: " + - std::string(std::strerror(errno))); - } + if (!macos_app_install_path_is_safe(installed_bundle, error)) return false; + const fs::path parent = installed_bundle.parent_path(); + if (::access(parent.c_str(), W_OK | X_OK) != 0) { + set_error(error, "ACECode cannot modify " + parent.string() + ": " + + std::strerror(errno) + + "; move ACECode.app to a writable folder or install manually"); return false; } return true; @@ -430,12 +316,20 @@ bool authenticate_candidate(const fs::path& reference_bundle, } bool acquire(const fs::path& path, std::string* error) { - fd_ = ::open(path.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0600); + fd_ = ::open(path.c_str(), O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK, 0600); if (fd_ < 0) { set_error(error, "cannot open the macOS update lock: " + std::string(std::strerror(errno))); return false; } + struct stat status{}; + if (::fstat(fd_, &status) != 0 || !S_ISREG(status.st_mode) || + status.st_uid != ::geteuid() || status.st_nlink != 1) { + set_error(error, "the macOS update lock must be a real, singly linked file owned by the current user"); + (void)::close(fd_); + fd_ = -1; + return false; + } if (::flock(fd_, LOCK_EX | LOCK_NB) != 0) { set_error(error, errno == EWOULDBLOCK ? "another macOS update is already installing" @@ -514,6 +408,7 @@ bool install_macos_app_update(const fs::path& installed_bundle, UpdateLock lock; record("macos_lock", {{"path", (applications_dir / kUpdateLockName).u8string()}}); if (!lock.acquire(applications_dir / kUpdateLockName, error)) return false; + if (!validate_safe_install_path(installed_bundle, error)) return false; NSFileManager* file_manager = [NSFileManager defaultManager]; NSURL* installed_url = file_url(installed_bundle, YES); @@ -521,7 +416,9 @@ bool install_macos_app_update(const fs::path& installed_bundle, const fs::path temporary_path = applications_dir / (".ACECode-" + std::string([[[NSUUID UUID] UUIDString] UTF8String]) + ".updating.app"); - const fs::path previous_path = applications_dir / kPreviousBundleName; + const fs::path previous_path = applications_dir / + (".ACECode-" + std::string([[[NSUUID UUID] UUIDString] UTF8String]) + + ".previous.app"); NSURL* temporary_url = file_url(temporary_path, YES); NSURL* previous_url = file_url(previous_path, YES); if (!installed_url || !candidate_url || !temporary_url || !previous_url) { @@ -551,13 +448,6 @@ bool install_macos_app_update(const fs::path& installed_bundle, return false; } - record("macos_backup_clear", {{"backup", previous_path.u8string()}}); - if (!remove_if_present(file_manager, previous_url, error)) { - std::string ignored; - remove_if_present(file_manager, temporary_url, &ignored); - record("macos_candidate_cleanup", {{"error", ignored}}); - return false; - } record("macos_backup_move", {{"source", installed_bundle.u8string()}, {"destination", previous_path.u8string()}}); if (!move_item(file_manager, installed_url, previous_url, error)) { diff --git a/src/upgrade/macos_bundle.cpp b/src/upgrade/macos_bundle.cpp index 294ec025b..ff19d6d41 100644 --- a/src/upgrade/macos_bundle.cpp +++ b/src/upgrade/macos_bundle.cpp @@ -1,5 +1,7 @@ #include "macos_bundle.hpp" +#include +#include #include namespace fs = std::filesystem; @@ -31,6 +33,27 @@ bool is_complete_bundle_layout(const fs::path& bundle) { } // namespace +bool macos_app_install_path_is_safe(const fs::path& bundle, std::string* error) { + auto reject = [&]() { + if (error) *error = "macOS self-update requires an absolute real ACECode.app " + "with a real parent, outside App Translocation and other app bundles"; + return false; + }; + if (!bundle.is_absolute() || bundle != bundle.lexically_normal() || + bundle.filename() != "ACECode.app") return reject(); + for (const auto& component : bundle.parent_path()) { + std::string name = component.string(); + std::transform(name.begin(), name.end(), name.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (name == "apptranslocation" || + fs::path(name).extension() == ".app") return reject(); + } + std::error_code ec; + if (!is_real_directory(bundle) || !is_real_directory(bundle.parent_path()) || + fs::canonical(bundle, ec) != bundle || ec) return reject(); + return true; +} + std::optional macos_app_bundle_from_executable( const fs::path& executable) { const auto generic = executable.generic_string(); diff --git a/src/upgrade/macos_bundle.hpp b/src/upgrade/macos_bundle.hpp index bf046299b..6ff37f191 100644 --- a/src/upgrade/macos_bundle.hpp +++ b/src/upgrade/macos_bundle.hpp @@ -6,6 +6,12 @@ namespace acecode::upgrade { +// Require an absolute, canonical, real ACECode.app and parent; reject App +// Translocation and bundles nested in another application. Does not check trust. +bool macos_app_install_path_is_safe( + const std::filesystem::path& bundle, + std::string* error = nullptr); + // Return the enclosing application bundle only for the production daemon // layout: .../ACECode.app/Contents/MacOS/acecode-daemon. The helper is kept // portable so layout policy can be covered by the normal unit suite. diff --git a/src/web/routes/routes_sessions.cpp b/src/web/routes/routes_sessions.cpp index c87f094a2..75e7829d0 100644 --- a/src/web/routes/routes_sessions.cpp +++ b/src/web/routes/routes_sessions.cpp @@ -825,6 +825,11 @@ void WebServer::Impl::register_sessions() { ([this](const crow::request& req, const std::string&) { return cors_preflight(req); }); + + CROW_ROUTE(app, "/api/sessions//messages/retry").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req, const std::string&) { + return cors_preflight(req); + }); CROW_ROUTE(app, "/api/sessions//trajectory").methods(crow::HTTPMethod::Options) ([this](const crow::request& req, const std::string&) { return cors_preflight(req); @@ -1911,6 +1916,36 @@ void WebServer::Impl::register_sessions() { return with_cors(req, std::move(r)); }); + CROW_ROUTE(app, "/api/sessions//messages/retry").methods(crow::HTTPMethod::POST) + ([this](const crow::request& req, const std::string& id) { + if (auto rej = require_auth(req)) return std::move(*rej); + if (auto rej = reject_if_migrating(req)) return std::move(*rej); + auto respond = [&](int status, const json& body) { + crow::response response(status); + response.body = body.dump(); + response.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(response)); + }; + if (!deps.session_client) { + return respond(503, {{"error", "session client unavailable"}}); + } + const auto payload = json::parse(req.body, nullptr, false); + if (!payload.is_object() || payload.size() != 1 || + !payload.contains("expected_user_message_id") || + !payload["expected_user_message_id"].is_string()) { + return respond(400, {{"error", "expected_user_message_id is required"}}); + } + const auto message_id = payload["expected_user_message_id"].get(); + if (message_id.empty() || message_id.size() > 256) { + return respond(400, {{"error", "invalid expected_user_message_id"}}); + } + std::string error; + if (!deps.session_client->retry_last_user_message(id, message_id, error)) { + return respond(409, {{"error", error}}); + } + return respond(202, {{"queued", true}, {"user_message_id", message_id}}); + }); + // Soft steer: append structured input to the matching active turn and // consume it at the next model boundary. CROW_ROUTE(app, "/api/sessions//turn/steer").methods(crow::HTTPMethod::POST) diff --git a/tests/agent_loop/agent_loop_turn_steering_test.cpp b/tests/agent_loop/agent_loop_turn_steering_test.cpp index c9fa3e27a..ca153b6f2 100644 --- a/tests/agent_loop/agent_loop_turn_steering_test.cpp +++ b/tests/agent_loop/agent_loop_turn_steering_test.cpp @@ -4,6 +4,7 @@ #include "permissions.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" +#include "session/turn_timing.hpp" #include "stub_provider.hpp" #include "tool/tool_executor.hpp" @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -149,6 +151,144 @@ std::vector request_user_texts( } // namespace +TEST(AgentLoopUserMessageRetry, ReusesStructuredTailWithoutDuplicatingUser) { + TurnSteeringHarness h("retry-structured"); + acecode::ChatMessage user; + user.role = "user"; + user.uuid = "retry-user-1"; + user.content = "expanded skill and selection context"; + user.content_parts = nlohmann::json::array({ + {{"type", "text"}, {"text", user.content}}, + {{"type", "image_url"}, {"image_url", {{"url", "data:image/png;base64,AAAA"}}}}, + }); + user.metadata = {{"display_text", "/skill original"}, + {"attachments", nlohmann::json::array({{{"id", "att-1"}}})}}; + h.loop().push_message(user); + h.session_manager().on_message(user); + const auto timing = acecode::make_turn_timing_message( + {user.uuid, 1, 2, 1, "aborted"}, "2026-09-18T00:00:00Z"); + h.loop().push_message(timing); + h.session_manager().on_message(timing); + + h.provider().push_text("retried response"); + h.provider().set_latency_ms(100); + std::string error; + ASSERT_TRUE(h.loop().retry_last_user_message(user.uuid, error)) << error; + EXPECT_FALSE(h.loop().retry_last_user_message(user.uuid, error)); + ASSERT_TRUE(h.wait_for_provider_turns_and_idle(1)); + + const auto request = h.provider().messages_for_turn(0); + const auto sent = std::find_if(request.begin(), request.end(), [&](const auto& message) { + return message.uuid == user.uuid; + }); + ASSERT_NE(sent, request.end()); + EXPECT_EQ(sent->content, user.content); + EXPECT_EQ(sent->content_parts, user.content_parts); + EXPECT_EQ(sent->metadata, user.metadata); + EXPECT_EQ(request_user_texts(request), std::vector{user.content}); + const auto persisted = h.session_manager().load_active_messages(); + EXPECT_EQ(std::count_if(persisted.begin(), persisted.end(), [](const auto& message) { + return message.role == "user"; + }), 1); + for (const auto& event : h.events()) { + EXPECT_FALSE(event.kind == acecode::SessionEventKind::Message && + event.payload.value("role", std::string{}) == "user"); + } + EXPECT_FALSE(h.loop().retry_last_user_message(user.uuid, error)); + EXPECT_EQ(h.provider().turn_count(), 1); +} + +TEST(AgentLoopUserMessageRetry, RejectsEveryVisibleNonUserTailIncludingEmptyAssistant) { + for (const auto& role : {"assistant", "tool", "system", "error"}) { + SCOPED_TRACE(role); + TurnSteeringHarness h(std::string("retry-tail-") + role); + acecode::ChatMessage user; + user.role = "user"; + user.uuid = "user-before-tail"; + user.content = "original"; + h.loop().push_message(user); + h.session_manager().on_message(user); + acecode::ChatMessage tail; + tail.role = role; + // In particular, an empty assistant is still a non-user tail. + h.loop().push_message(tail); + h.session_manager().on_message(tail); + std::string error; + EXPECT_FALSE(h.loop().retry_last_user_message(user.uuid, error)); + EXPECT_EQ(h.provider().turn_count(), 0); + } +} + +TEST(AgentLoopUserMessageRetry, ChecksPersistedTranscriptNotOnlyModelHistory) { + TurnSteeringHarness h("retry-persisted-tail"); + acecode::ChatMessage user; + user.role = "user"; + user.uuid = "user-before-notice"; + user.content = "original"; + h.loop().push_message(user); + h.session_manager().on_message(user); + h.loop().emit_transcript_system_message("later transcript-only notice"); + std::string error; + EXPECT_FALSE(h.loop().retry_last_user_message(user.uuid, error)); + EXPECT_EQ(h.provider().turn_count(), 0); +} + +TEST(AgentLoopUserMessageRetry, RejectsEmptyHistoryAndStaleIdentity) { + TurnSteeringHarness h("retry-stale-id"); + std::string error; + EXPECT_FALSE(h.loop().retry_last_user_message("", error)); + EXPECT_FALSE(h.loop().retry_last_user_message("missing", error)); + acecode::ChatMessage user; + user.role = "user"; + user.uuid = "latest-user"; + user.content = "latest"; + h.loop().push_message(user); + h.session_manager().on_message(user); + EXPECT_FALSE(h.loop().retry_last_user_message("older-user", error)); + EXPECT_EQ(h.provider().turn_count(), 0); +} + +TEST(AgentLoopUserMessageRetry, RejectsVisibleEventsAbsentFromStoredHistory) { + for (const auto kind : {acecode::SessionEventKind::Message, + acecode::SessionEventKind::Token, + acecode::SessionEventKind::Reasoning, + acecode::SessionEventKind::ToolStart, + acecode::SessionEventKind::Error}) { + TurnSteeringHarness h("retry-live-tail"); + acecode::ChatMessage user; + user.role = "user"; + user.uuid = "retry-live-user"; + user.content = "original"; + h.loop().push_message(user); + h.session_manager().on_message(user); + h.loop().events().emit(kind, {{"role", "error"}, {"text", "partial"}, + {"content", "error notice"}, {"reason", "failed"}}); + std::string error; + EXPECT_FALSE(h.loop().retry_last_user_message(user.uuid, error)); + EXPECT_EQ(h.provider().turn_count(), 0); + } +} + +TEST(AgentLoopUserMessageRetry, RejectsActiveOrQueuedControlWork) { + TurnSteeringHarness h("retry-control-work"); + acecode::ChatMessage user; + user.role = "user"; + user.uuid = "retry-control-user"; + user.content = "original"; + h.loop().push_message(user); + h.session_manager().on_message(user); + std::promise release; + auto gate = release.get_future().share(); + h.loop().enqueue_control([gate] { + gate.wait_for(2s); + return true; + }); + std::string error; + EXPECT_FALSE(h.loop().retry_last_user_message(user.uuid, error)); + release.set_value(); + EXPECT_EQ(h.provider().turn_count(), 0); +} + TEST(AgentLoopTurnSteering, RejectsInvalidIdleAndMismatchedRequests) { TurnSteeringHarness h("reject"); diff --git a/tests/desktop/desktop_restart_test.cpp b/tests/desktop/desktop_restart_test.cpp index c5998463e..aeaf28aea 100644 --- a/tests/desktop/desktop_restart_test.cpp +++ b/tests/desktop/desktop_restart_test.cpp @@ -53,6 +53,16 @@ TEST(DesktopRestart, RejectsEmptyAndMissingTargets) { EXPECT_NE(missing.error.find("does not exist"), std::string::npos); } +#ifdef __APPLE__ +TEST(DesktopRestart, CapturesCanonicalExecutablePath) { + const auto executable = acecode::desktop::current_desktop_executable_path(); + ASSERT_FALSE(executable.empty()); + EXPECT_TRUE(executable.is_absolute()); + EXPECT_EQ(executable, fs::canonical(executable)); + EXPECT_TRUE(validate_desktop_restart_target(executable).ok); +} +#endif + TEST(DesktopRestart, RejectsDirectoryTarget) { TempDir temp; auto result = validate_desktop_restart_target(temp.path()); diff --git a/tests/scripts/macos_installer_release_test.py b/tests/scripts/macos_installer_release_test.py new file mode 100644 index 000000000..ee75ad028 --- /dev/null +++ b/tests/scripts/macos_installer_release_test.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Offline contracts and mocked release flow; never invoke signing/notary tools.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = (ROOT / '.github/workflows/package.yml').read_text() + +# All security-sensitive tools in temporary script copies are replaced below. +MOCK = r''' +import json, os, pathlib, shutil, sys +tool, *args = sys.argv[1:] +root = pathlib.Path(os.environ['TEST_ROOT']) +with (root / 'calls').open('a') as f: f.write(json.dumps([tool, *args]) + '\n') +def value(flag): return args[args.index(flag) + 1] +if tool == 'uname': print('Darwin') +elif tool == 'codesign': + if '-dv' in args: + print('TeamIdentifier=T52GZCH73Y\nAuthority=Developer ID Application: Test') + if '--sign' in args and os.environ.get('MUTATE_PAYLOAD'): + target = pathlib.Path(args[-1]) / 'Contents/Resources/ACECode.app/Contents/MacOS/ACECode' + if target.exists(): target.write_text('mutated') +elif tool == 'plutil': + if '-extract' in args: + key = value('-extract') + if key in ('status', 'id'): print(json.loads(pathlib.Path(args[-1]).read_text())[key]) + else: print({'CFBundleIdentifier':'dev.acecode.desktop', 'CFBundleShortVersionString':'1.2.3', 'CFBundleVersion':'123', 'CFBundleIconFile':'app.icns'}[key]) + elif '-create' in args: pathlib.Path(args[-1]).touch() +elif tool == 'ditto': + source, target = map(pathlib.Path, args[-2:]) + if '-c' in args: target.write_text('zip') + elif source.is_dir(): shutil.copytree(source, target, symlinks=True) + else: shutil.copy2(source, target) +elif tool == 'hdiutil': + image = pathlib.Path(value('-srcfolder')) + assert [p.name for p in image.iterdir()] == ['ACECode Installer.app'] + pathlib.Path(args[-1]).write_text('dmg') +elif tool == 'xcrun': + if '--show-sdk-path' in args: print('/mock-sdk') + elif args[0] == 'swiftc': pathlib.Path(value('-o')).write_text('binary') + elif args[:2] == ['notarytool', 'submit']: + status = os.environ.get('NOTARY_STATUS', 'Accepted') + if args[2].endswith('.dmg') and os.environ.get('REJECT_DMG'): status = 'Invalid' + print(json.dumps({'status': status, 'id': 'test-submission'})) +elif tool == 'lipo': + assert args[1] == '-verify_arch' and args[2] in ('x86_64', 'arm64') + if os.environ.get('WRONG_ARCH'): sys.exit(1) +''' + + +class InstallerReleaseTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='installer release ') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.tools = self.root / 'tools' + self.tools.mkdir() + (self.tools / 'mock.py').write_text(MOCK) + for tool in ('codesign', 'spctl', 'plutil', 'ditto', 'hdiutil', 'lipo', 'xcrun', 'uname'): + path = self.tools / tool + path.write_text('#!/bin/bash\nexec python3 "$(dirname "$0")/mock.py" ' + tool + ' "$@"\n') + path.chmod(0o755) + for name in ('scripts/macos_create_installer_dmg.sh', 'packaging/macos-installer/build.sh'): + target = self.root / name + target.parent.mkdir(parents=True, exist_ok=True) + text = (ROOT / name).read_text() + for tool in ('codesign', 'plutil', 'ditto', 'hdiutil', 'lipo'): + text = text.replace('/usr/bin/' + tool, '"' + str(self.tools / tool) + '"') + text = text.replace('/usr/sbin/spctl', '"' + str(self.tools / 'spctl') + '"') + target.write_text(text) + self.app = self.root / 'payload/ACECode.app' + (self.app / 'Contents/MacOS').mkdir(parents=True) + (self.app / 'Contents/Resources').mkdir() + for name in ('MacOS/ACECode', 'MacOS/acecode-daemon', 'Resources/app.icns', 'Info.plist'): + (self.app / 'Contents' / name).write_text('original') + self.output = self.root / 'output/ACECode-1.2.3-macos-x64.dmg' + self.env = dict(os.environ, TEST_ROOT=str(self.root), PATH=str(self.tools) + os.pathsep + os.environ['PATH']) + + def run_pipeline(self, success=True, arch='x64'): + result = subprocess.run(['bash', str(self.root / 'scripts/macos_create_installer_dmg.sh'), + '--app', str(self.app), '--output', str(self.output), '--arch', arch, + '--identity', 'test-fingerprint', '--keychain', '/mock-keychain', + '--keychain-profile', 'offline-test'], env=self.env, capture_output=True, text=True) + self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) + return [json.loads(line) for line in (self.root / 'calls').read_text().splitlines()] + + def test_release_flow_both_architectures(self): + for arch, target in [('x64', 'x86_64'), ('arm64', 'arm64')]: + self.output = self.root / f'{arch}.dmg' + calls = self.run_pipeline(arch=arch) + self.assertTrue(self.output.is_file()) + compile_call = [c for c in calls if c[:2] == ['xcrun', 'swiftc']][-1] + self.assertIn(target + '-apple-macosx11.0', compile_call) + signs = [c for c in calls if c[0] == 'codesign' and '--sign' in c] + self.assertTrue(all('--deep' not in c for c in signs)) + self.assertTrue(all('/mock-keychain' in c for c in signs)) + self.assertIn('runtime', signs[-2]) + self.assertTrue(signs[-2][-1].endswith('ACECode Installer.app')) + self.assertTrue(signs[-1][-1].endswith('release.dmg')) + submissions = [c for c in calls if c[:3] == ['xcrun', 'notarytool', 'submit']] + self.assertTrue(submissions[-2][3].endswith('installer.zip')) + self.assertTrue(submissions[-1][3].endswith('release.dmg')) + self.assertTrue(all('--wait' in c for c in submissions)) + self.assertEqual((self.app / 'Contents/MacOS/ACECode').read_text(), 'original') + + def test_rejected_notarization_never_creates_dmg(self): + self.env['NOTARY_STATUS'] = 'Invalid' + calls = self.run_pipeline(success=False) + self.assertFalse(self.output.exists()) + self.assertFalse(any(c[0] == 'hdiutil' for c in calls)) + self.assertTrue(any(c[:3] == ['xcrun', 'notarytool', 'log'] for c in calls)) + + def test_wrong_architecture_fails_before_signing(self): + self.env['WRONG_ARCH'] = '1' + calls = self.run_pipeline(success=False) + self.assertFalse(any('--sign' in c for c in calls)) + + def test_rejected_dmg_notarization_never_publishes(self): + self.env['REJECT_DMG'] = '1' + calls = self.run_pipeline(success=False) + self.assertTrue(any(c[0] == 'hdiutil' for c in calls)) + self.assertFalse(self.output.exists()) + + def test_cli_validation_without_tools(self): + for script in ('packaging/macos-installer/build.sh', 'scripts/macos_create_installer_dmg.sh'): + for args, status in [(['--help'], 0), ([], 2), (['--arch'], 2), (['--unknown'], 2)]: + result = subprocess.run(['bash', str(ROOT / script), *args], capture_output=True) + self.assertEqual(result.returncode, status) + + def test_resigned_or_modified_payload_fails_before_notarization(self): + self.env['MUTATE_PAYLOAD'] = '1' + calls = self.run_pipeline(success=False) + self.assertFalse(any('notarytool' in c for c in calls)) + + def test_workflow_order_and_gate(self): + step = WORKFLOW.split('- name: Build signed and notarized macOS installer DMG', 1)[1].split('- name:', 1)[0] + self.assertIn("steps.macos-release.outputs.enabled == 'true'", step) + self.assertNotIn('pkg_enabled', step) + self.assertIn('--app "build/ACECode.app"', step) + self.assertIn('--arch "${{ matrix.installer_arch }}"', step) + self.assertLess(WORKFLOW.index('- name: Notarize and staple macOS app'), WORKFLOW.index('- name: Build signed')) + self.assertLess(WORKFLOW.index('- name: Build signed'), WORKFLOW.index('- name: Clean up macOS signing material')) + self.assertIn('installer_arch: x64', WORKFLOW) + self.assertIn('installer_arch: arm64', WORKFLOW) + + def test_dmg_release_naming_and_count(self): + block = WORKFLOW.split(' - name: Collect release assets', 1)[1].split(' - name:', 1)[0] + block = textwrap.dedent(block.split(' run: |\n', 1)[1]) + # Exercise the real DMG guard, not a reimplementation of it. + guard = block[block.index('dmg_count='):block.index('unsigned_pkg=')] + artifacts = self.root / 'artifacts' + artifacts.mkdir() + def check(names, success): + for p in artifacts.rglob('*'): + if p.is_file(): p.unlink() + for name in names: + path = artifacts / name + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + result = subprocess.run(['bash', '-eu', '-c', 'release_version=1.2.3\n' + guard], cwd=self.root, capture_output=True) + self.assertEqual(result.returncode == 0, success, result.stderr) + valid = ['ACECode-1.2.3-macos-x64.dmg', 'ACECode-1.2.3-macos-arm64.dmg'] + check(valid, True) + check([], False) + check(valid[:1], False) + check(valid + ['extra.dmg'], False) + check([valid[0], 'old.dmg'], False) + check([valid[0], 'nested/' + valid[0]], False) + check([valid[0], 'ACECode-1.2.3-macos-arm64-unsigned.dmg'], False) + + def test_updater_selection_excludes_installers(self): + spec = importlib.util.spec_from_file_location('selector', ROOT / 'scripts/select_macos_update_assets.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + for arch in ('x64', 'arm64'): + for suffix in ('.dmg', '.pkg', '-update.zip', '-update-unsigned.zip'): + (self.root / f'ACECode-1.2.3-macos-{arch}{suffix}').touch() + selected = module.select_assets(self.root, '1.2.3') + self.assertEqual(len(selected), 2) + self.assertTrue(all(p.name.endswith('-update.zip') for p in selected)) + selected[0].unlink() + with self.assertRaises(ValueError): module.select_assets(self.root, '1.2.3') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/scripts/macos_installer_test.sh b/tests/scripts/macos_installer_test.sh new file mode 100644 index 000000000..0710ce228 --- /dev/null +++ b/tests/scripts/macos_installer_test.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# No certificates, keychain access or network submissions. +set -euo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" +[[ "$(uname -s)" == Darwin ]] || { printf 'SKIP: Swift/AppKit tests require macOS\n'; exit 0; } +work="$(mktemp -d "${TMPDIR:-/tmp}/acecode-installer-tests.XXXXXX")" +trap 'rm -rf -- "$work"' EXIT +source_dir="$root/packaging/macos-installer" +xcrun swiftc -swift-version 5 -Onone "$source_dir/Engine.swift" "$source_dir/Tests.swift" -o "$work/tests" +python3 - "$work/tests" <<'PY' +import subprocess +import sys +subprocess.run([sys.argv[1]], check=True, timeout=120) +PY +for arch in x86_64 arm64; do + xcrun swiftc -swift-version 5 -O -target "${arch}-apple-macosx11.0" \ + -sdk "$(xcrun --sdk macosx --show-sdk-path)" -framework AppKit \ + "$source_dir/Engine.swift" "$source_dir/main.swift" -o "$work/installer-$arch" + /usr/bin/lipo "$work/installer-$arch" -verify_arch "$arch" + xcrun vtool -show-build "$work/installer-$arch" | grep -Eq 'minos[[:space:]]+11\.0' +done + +# Exercise application startup as a real bundle, not only Swift compilation. +smoke_app="$work/ACECode Installer.app" +mkdir -p "$smoke_app/Contents/MacOS" "$smoke_app/Contents/Resources/ACECode.app/Contents" +cp "$work/installer-$(uname -m)" "$smoke_app/Contents/MacOS/ACECodeInstaller" +python3 - "$smoke_app" <<'PY' +from pathlib import Path +import plistlib +import subprocess +import sys + +app = Path(sys.argv[1]) +with (app / 'Contents/Info.plist').open('wb') as stream: + plistlib.dump({'CFBundleExecutable': 'ACECodeInstaller', + 'CFBundleIdentifier': 'dev.acecode.userinstaller.tests', + 'CFBundlePackageType': 'APPL'}, stream) +with (app / 'Contents/Resources/ACECode.app/Contents/Info.plist').open('wb') as stream: + plistlib.dump({'CFBundleIdentifier': 'dev.acecode.desktop', + 'CFBundleShortVersionString': '0.0.0'}, stream) +for scope in ('personal', 'system'): + image = app.parent / f'installer-{scope}.png' + args = [str(app / 'Contents/MacOS/ACECodeInstaller'), '--snapshot', str(image)] + if scope == 'system': + args.append('--preview-system') + subprocess.run(args, check=True, timeout=30) + assert image.read_bytes().startswith(b'\x89PNG\r\n\x1a\n'), 'Missing window snapshot' +print('Native installer window startup passed (personal and system scopes)') +PY diff --git a/tests/scripts/macos_release_scripts_test.sh b/tests/scripts/macos_release_scripts_test.sh index 7051e1c90..be11224f8 100755 --- a/tests/scripts/macos_release_scripts_test.sh +++ b/tests/scripts/macos_release_scripts_test.sh @@ -71,7 +71,8 @@ grep -Fq 'scripts/macos_create_pkg.sh' "$package_workflow" grep -Fq 'scripts/macos_notarize_pkg.sh' "$package_workflow" grep -Fq -- '--installer-identity "${{ steps.macos-keychain.outputs.installer_identity }}"' "$package_workflow" grep -Fq 'acecode-${{ matrix.id }}-pkg' "$package_workflow" -grep -Fq 'DMG artifacts are no longer permitted in tagged releases' "$package_workflow" +grep -Fq 'Tagged releases require exactly two signed macOS installer DMGs' "$package_workflow" +grep -Fq 'ACECode-${release_version}-macos-${arch}.dmg' "$package_workflow" grep -Fq 'Unsigned PKG artifacts must not be published' "$package_workflow" grep -Fq 'Unsigned macOS update artifacts must not be published' "$package_workflow" grep -Fq 'Tagged releases allow either zero or two signed macOS PKGs' "$package_workflow" @@ -91,17 +92,7 @@ if grep -Fq 'identity="$MACOS_CODESIGN_IDENTITY"' "$package_workflow"; then exit 1 fi -if grep -ERni \ - --exclude='macos_create_pkg.sh' \ - 'macos_create_dmg|macos_notarize\.sh|macos-dmg' \ - "$package_workflow" "$repo_root/scripts"; then - echo "Active macOS release automation must not retain DMG packaging" >&2 - exit 1 -fi -if grep -Fq -- "-o -name '*.dmg'" "$package_workflow"; then - echo "Tagged release asset collection must not include DMG files" >&2 - exit 1 -fi +grep -Fq -- "-o -name '*.dmg'" "$package_workflow" if grep -En 'Applications\.app|acecode-user-applications|macos_verify_user_applications_drop' \ "$package_workflow"; then echo "Release workflow must not build or package the fake Applications target" >&2 @@ -118,6 +109,8 @@ if [[ "$(uname -s)" == "Darwin" ]]; then fake_app="$temporary_root/ACECode.app" mkdir -p "$fake_app/Contents/MacOS" mkdir -p "$fake_app/Contents/Resources/share/acecode/models_dev" + /usr/bin/ditto "$repo_root/assets/seed" \ + "$fake_app/Contents/Resources/share/acecode/seed" touch "$fake_app/Contents/MacOS/ACECode" \ "$fake_app/Contents/MacOS/acecode-daemon" printf '%s\n' '{}' > \ diff --git a/tests/session/global_session_catalog_test.cpp b/tests/session/global_session_catalog_test.cpp index abdfa91d1..eecfd0401 100644 --- a/tests/session/global_session_catalog_test.cpp +++ b/tests/session/global_session_catalog_test.cpp @@ -220,6 +220,42 @@ TEST(GlobalSessionCatalog, MergesActiveSessionsAndKeepsArchiveAuthoritative) { EXPECT_EQ(find_entry(catalog, "archived-live", first.hash), nullptr); } +TEST(GlobalSessionCatalog, CanIncludeArchivedAndChildSessionsForThreadDiscovery) { + TempProjectsRoot root; + const auto target = project(root, "C:/projects/thread-discovery"); + seed_meta(target, "archived", "2026-09-18T01:00:00Z", true); + seed_meta(target, "child", "2026-09-18T02:00:00Z", false, "parent"); + + acecode::SessionInfo archived; + archived.id = "archived"; + archived.cwd = target.cwd; + archived.workspace_hash = target.hash; + acecode::SessionInfo child = archived; + child.id = "active-child"; + child.parent_session_id = "parent"; + + acecode::GlobalSessionCatalogOptions options; + options.include_subagents = true; + const auto children = acecode::build_global_session_catalog( + root.path().string(), {archived, child}, options); + ASSERT_EQ(children.entries.size(), 2u); + EXPECT_EQ(find_entry(children, "archived"), nullptr); + EXPECT_NE(find_entry(children, "child"), nullptr); + EXPECT_NE(find_entry(children, "active-child"), nullptr); + + options.include_archived = true; + const auto all = acecode::build_global_session_catalog( + root.path().string(), {archived, child}, options); + ASSERT_EQ(all.entries.size(), 3u); + const auto* archived_entry = find_entry(all, "archived"); + ASSERT_NE(archived_entry, nullptr); + EXPECT_TRUE(archived_entry->meta.archived); + EXPECT_TRUE(archived_entry->active.has_value()); + + EXPECT_TRUE(acecode::build_global_session_catalog( + root.path().string(), {archived, child}).entries.empty()); +} + TEST(GlobalSessionCatalog, SearchesVisibleUserMessagesAcrossHiddenProjects) { TempProjectsRoot root; const auto hidden = project(root, "C:/projects/content-hidden", "Hidden", false); diff --git a/tests/tool/thread_tools_test.cpp b/tests/tool/thread_tools_test.cpp index 38a629dab..ec9a37be3 100644 --- a/tests/tool/thread_tools_test.cpp +++ b/tests/tool/thread_tools_test.cpp @@ -7,10 +7,13 @@ #include "session/session_storage.hpp" #include "session/thread_service.hpp" #include "tool/thread_tools.hpp" +#include "utils/paths.hpp" #include #include +#include #include +#include #include #include #include @@ -36,6 +39,64 @@ std::filesystem::path unique_cwd(const std::string& label) { return cwd; } +// Global discovery must never scan the developer's real session history. +class ThreadTools : public testing::Test { +protected: + std::filesystem::path root; + + void SetUp() override { + root = unique_cwd("home"); + if (const char* previous = std::getenv(home_key())) { + previous_home_ = previous; + had_home_ = true; + } + set_home(root.string()); + previous_mode_ = acecode::override_run_mode_for_test(acecode::RunMode::User); + acecode::reset_data_dir_cache_for_test(); + } + + void TearDown() override { + if (had_home_) { + set_home(previous_home_); + } else { +#ifdef _WIN32 + _putenv_s(home_key(), ""); +#else + unsetenv(home_key()); +#endif + } + acecode::override_run_mode_for_test(previous_mode_); + acecode::reset_data_dir_cache_for_test(); + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + + std::string workspace(const std::string& name) const { + const auto path = root / name; + std::filesystem::create_directories(path); + return path.string(); + } + +private: + static const char* home_key() { +#ifdef _WIN32 + return "USERPROFILE"; +#else + return "HOME"; +#endif + } + static void set_home(const std::string& value) { +#ifdef _WIN32 + _putenv_s(home_key(), value.c_str()); +#else + setenv(home_key(), value.c_str(), 1); +#endif + } + std::string previous_home_; + bool had_home_ = false; + acecode::RunMode previous_mode_ = acecode::RunMode::User; +}; + void persist_thread(const std::string& cwd, const std::string& id, const std::string& parent_id, @@ -57,7 +118,7 @@ void persist_thread(const std::string& cwd, } // namespace -TEST(ThreadTools, RegistersCodexAlignedIndependentToolNames) { +TEST_F(ThreadTools, RegistersCodexAlignedIndependentToolNames) { acecode::ToolExecutor tools; auto deps = std::make_shared(); deps->service = std::make_shared( @@ -90,6 +151,7 @@ TEST(ThreadTools, RegistersCodexAlignedIndependentToolNames) { EXPECT_TRUE(properties.contains("turnLimit")); EXPECT_TRUE(properties.contains("includeOutputs")); EXPECT_TRUE(properties.contains("maxOutputCharsPerItem")); + EXPECT_TRUE(properties.contains("workspaceHash")); EXPECT_FALSE(properties.contains("hostId")); EXPECT_FALSE(tools.has_tool("session_query")); EXPECT_FALSE(tools.has_tool("session_control")); @@ -105,7 +167,7 @@ TEST(ThreadTools, RegistersCodexAlignedIndependentToolNames) { std::string::npos); } -TEST(ThreadTools, ServiceListsPinnedAndReadsBoundedRecentTurns) { +TEST_F(ThreadTools, ServiceListsPinnedAndReadsBoundedRecentTurns) { const auto cwd = unique_cwd("list_read"); const std::string cwd_string = cwd.string(); const std::string project_dir = @@ -154,7 +216,215 @@ TEST(ThreadTools, ServiceListsPinnedAndReadsBoundedRecentTurns) { std::filesystem::remove_all(cwd); } -TEST(ThreadTools, DeleteCascadesToDescendantsAndCleansPins) { +TEST_F(ThreadTools, ListsAllProjectsWithPinsArchivesAndPaginationThroughTool) { + const auto first = workspace("first"); + const auto hidden = workspace("hidden"); + const auto standalone = workspace("standalone"); + const std::string first_pin = "20260918-100000-0001"; + const std::string hidden_pin = "20260918-100001-0002"; + const std::string hidden_pin_two = "20260918-100002-0003"; + const std::string child = "20260918-100003-0004"; + const std::string no_workspace = "20260918-100004-0005"; + const std::string archived = "20260918-100005-0006"; + persist_thread(first, first_pin, {}, "first workspace"); + persist_thread(hidden, hidden_pin, {}, "hidden workspace"); + persist_thread(hidden, hidden_pin_two, {}, "second pinned thread"); + persist_thread(hidden, child, hidden_pin, "child history"); + persist_thread(standalone, no_workspace, {}, "standalone history"); + persist_thread(hidden, archived, {}, "archived history"); + const auto first_project = acecode::SessionStorage::get_project_dir(first); + const auto hidden_project = acecode::SessionStorage::get_project_dir(hidden); + { + std::ofstream marker(std::filesystem::path(hidden_project) / "workspace.json"); + marker << nlohmann::json{{"cwd", hidden}, {"name", "Hidden project"}, + {"desktop_visible", false}}; + } + for (const auto& [cwd, id] : std::vector>{ + {standalone, no_workspace}, {hidden, archived}}) { + const auto path = acecode::SessionStorage::meta_path( + acecode::SessionStorage::get_project_dir(cwd), id); + auto meta = acecode::SessionStorage::read_meta(path); + meta.no_workspace = id == no_workspace; + meta.archived = id == archived; + ASSERT_TRUE(acecode::SessionStorage::write_meta(path, meta)); + } + ASSERT_TRUE(acecode::session_pins::write_pinned_sessions_state( + std::filesystem::path(first_project) / "pinned_sessions.json", {{first_pin}})); + ASSERT_TRUE(acecode::session_pins::write_pinned_sessions_state( + std::filesystem::path(hidden_project) / "pinned_sessions.json", + {{hidden_pin_two, hidden_pin, archived}})); + + acecode::ToolExecutor tools; + auto deps = std::make_shared(); + deps->service = std::make_shared(acecode::ThreadService::Deps{}); + acecode::register_codex_thread_tools(tools, deps); + acecode::ToolContext context; + context.cwd = first; + std::string cursor; + std::unordered_set regular_ids; + for (int page = 0; page < 3; ++page) { + const auto result = tools.execute("list_threads", + nlohmann::json{{"limit", 1}, {"cursor", cursor}}.dump(), context); + ASSERT_TRUE(result.success) << result.output; + const auto payload = nlohmann::json::parse(result.output); + EXPECT_TRUE(payload["errors"].empty()); + ASSERT_EQ(payload["pinnedThreads"].size(), 3u); + std::vector hidden_pins; + for (const auto& row : payload["pinnedThreads"]) { + if (row["cwd"] == hidden) { + hidden_pins.push_back(row["threadId"].get()); + EXPECT_EQ(row["workspaceName"], "Hidden project"); + EXPECT_FALSE(row["workspaceVisible"].get()); + } + } + EXPECT_EQ(hidden_pins, (std::vector{hidden_pin_two, hidden_pin})); + ASSERT_EQ(payload["threads"].size(), 1u); + const auto& row = payload["threads"][0]; + const auto id = row["threadId"].get(); + EXPECT_TRUE(regular_ids.insert(id).second); + EXPECT_FALSE(row["workspaceHash"].get().empty()); + EXPECT_EQ(row["noWorkspace"], id == no_workspace); + if (id == child) EXPECT_EQ(row["parentThreadId"], hidden_pin); + if (payload["nextCursor"].is_null()) { + EXPECT_FALSE(payload["hasMore"].get()); + break; + } + EXPECT_TRUE(payload["hasMore"].get()); + cursor = payload["nextCursor"].get(); + } + EXPECT_EQ(regular_ids, (std::unordered_set{child, no_workspace})); + + // No calling cwd is needed, and includeArchived reaches the stored archive. + const auto result = tools.execute("list_threads", R"({"includeArchived":true})"); + ASSERT_TRUE(result.success) << result.output; + const auto payload = nlohmann::json::parse(result.output); + ASSERT_EQ(payload["threads"].size(), 3u); + const auto archived_row = std::find_if(payload["threads"].begin(), + payload["threads"].end(), [&](const auto& row) { return row["threadId"] == archived; }); + ASSERT_NE(archived_row, payload["threads"].end()); + EXPECT_TRUE((*archived_row)["archived"].get()); + EXPECT_FALSE(tools.execute("list_threads", R"({"cursor":"-1"})").success); +} + +TEST_F(ThreadTools, ReadsArchivedChildAndWorkspaceFreeHistoryAcrossProjects) { + const auto source = workspace("source"); + const auto target = workspace("target"); + const std::string id = "20260918-110000-0001"; + persist_thread(target, id, "parent-thread", "history from another workspace"); + const auto meta_path = acecode::SessionStorage::meta_path( + acecode::SessionStorage::get_project_dir(target), id); + auto meta = acecode::SessionStorage::read_meta(meta_path); + meta.archived = true; + meta.no_workspace = true; + ASSERT_TRUE(acecode::SessionStorage::write_meta(meta_path, meta)); + + acecode::ToolExecutor tools; + auto deps = std::make_shared(); + deps->service = std::make_shared(acecode::ThreadService::Deps{}); + acecode::register_codex_thread_tools(tools, deps); + acecode::ToolContext context; + context.cwd = source; + for (const auto& hash : {std::string{}, acecode::SessionStorage::compute_project_hash(target)}) { + const auto result = tools.execute("read_thread", nlohmann::json{ + {"threadId", id}, {"workspaceHash", hash}}.dump(), context); + ASSERT_TRUE(result.success) << result.output; + const auto payload = nlohmann::json::parse(result.output); + EXPECT_EQ(payload["cwd"], target); + EXPECT_TRUE(payload["noWorkspace"].get()); + EXPECT_EQ(payload["turns"][0]["items"][0]["content"], + "history from another workspace"); + } + // Known ids work even with no calling workspace. + EXPECT_TRUE(tools.execute("read_thread", + nlohmann::json{{"threadId", id}}.dump()).success); + EXPECT_FALSE(tools.execute("read_thread", + R"({"threadId":"../outside"})").success); + EXPECT_FALSE(tools.execute("read_thread", nlohmann::json{ + {"threadId", id}, {"workspaceHash", "../outside"}}.dump()).success); +} + +TEST_F(ThreadTools, DisambiguatesIdenticalIdsUsingReturnedWorkspaceHash) { + const auto first = workspace("first"); + const auto second = workspace("second"); + const std::string id = "20260918-120000-0001"; + persist_thread(first, id, {}, "first history"); + persist_thread(second, id, {}, "second history"); + acecode::ThreadService service({}); + acecode::ThreadScope scope; + scope.cwd = first; + const auto ambiguous = service.read(scope, id); + EXPECT_FALSE(ambiguous.success); + EXPECT_NE(ambiguous.error.find("workspaceHash"), std::string::npos); + const auto listed = service.list(scope); + ASSERT_TRUE(listed.success) << listed.error; + ASSERT_EQ(listed.value["threads"].size(), 2u); + for (const auto& row : listed.value["threads"]) { + const auto read = service.read(scope, id, {}, 8, false, 2000, + row["workspaceHash"].get()); + ASSERT_TRUE(read.success) << read.error; + EXPECT_EQ(read.value["turns"][0]["items"][0]["content"], + row["cwd"] == first ? "first history" : "second history"); + } +} + +TEST_F(ThreadTools, ReadsAndWaitsForActiveThreadsInAnotherWorkspace) { + const auto first = workspace("caller"); + const auto second = workspace("active-target"); + acecode::ToolExecutor tools; + acecode::PermissionManager permissions; + acecode::SessionRegistryDeps registry_deps; + registry_deps.provider_accessor = [] { return std::shared_ptr{}; }; + registry_deps.tools = &tools; + registry_deps.cwd = first; + registry_deps.template_permissions = &permissions; + acecode::SessionRegistry registry(std::move(registry_deps)); + acecode::LocalSessionClient client(registry); + acecode::SessionOptions options; + options.cwd = second; + const auto id = registry.create(options); + auto entry = registry.acquire(id); + ASSERT_NE(entry, nullptr); + auto deps = std::make_shared(); + deps->service = std::make_shared( + acecode::ThreadService::Deps{®istry, &client}); + acecode::register_codex_thread_tools(tools, deps); + + acecode::ThreadScope scope; + scope.cwd = first; + const auto listed = deps->service->list(scope); + ASSERT_TRUE(listed.success) << listed.error; + ASSERT_EQ(listed.value["threads"].size(), 1u); + EXPECT_EQ(listed.value["threads"][0]["threadId"], id); + EXPECT_TRUE(listed.value["threads"][0]["active"].get()); + entry->sm->on_message(message("user", "live history")); + const auto read = deps->service->read(scope, id); + ASSERT_TRUE(read.success) << read.error; + EXPECT_TRUE(read.value["active"].get()); + EXPECT_EQ(read.value["turns"][0]["items"][0]["content"], "live history"); + + const auto seq = entry->loop->events().emit( + acecode::SessionEventKind::Done, {{"outcome", "success"}}); + acecode::ToolContext context; + context.cwd = first; + const auto result = tools.execute("wait_threads", nlohmann::json{ + {"targets", nlohmann::json::array({{ + {"threadId", id}, {"workspaceHash", entry->workspace_hash}, + {"afterCursor", "0"}}})}, {"timeoutMs", 0}}.dump(), context); + ASSERT_TRUE(result.success) << result.output; + const auto payload = nlohmann::json::parse(result.output); + ASSERT_EQ(payload["threads"].size(), 1u); + EXPECT_TRUE(payload["errors"].empty()); + EXPECT_TRUE(payload["threads"][0]["active"].get()); + EXPECT_EQ(payload["threads"][0]["cursor"], std::to_string(seq)); + EXPECT_EQ(payload["winnerThreadId"], id); + + scope.cwd = second; + scope.caller_thread_id = id; + EXPECT_FALSE(deps->service->wait(scope, {{id, 0}}, 0).success); + registry.destroy(id); +} + +TEST_F(ThreadTools, DeleteCascadesToDescendantsAndCleansPins) { const auto cwd = unique_cwd("delete_tree"); const std::string cwd_string = cwd.string(); const std::string project_dir = @@ -200,7 +470,7 @@ TEST(ThreadTools, DeleteCascadesToDescendantsAndCleansPins) { std::filesystem::remove_all(cwd); } -TEST(ThreadTools, SelfDeleteWaitsForTuiTurnBoundaryBeforePurging) { +TEST_F(ThreadTools, SelfDeleteWaitsForTuiTurnBoundaryBeforePurging) { const auto cwd = unique_cwd("self_delete_tui"); const std::string cwd_string = cwd.string(); const std::string project_dir = @@ -248,7 +518,7 @@ TEST(ThreadTools, SelfDeleteWaitsForTuiTurnBoundaryBeforePurging) { std::filesystem::remove_all(cwd); } -TEST(ThreadTools, RegistrySelfDeleteUsesExternalLifecycleWithEntrySnapshotHeld) { +TEST_F(ThreadTools, RegistrySelfDeleteUsesExternalLifecycleWithEntrySnapshotHeld) { const auto cwd = unique_cwd("self_delete_registry"); const std::string cwd_string = cwd.string(); const std::string project_dir = @@ -308,7 +578,7 @@ TEST(ThreadTools, RegistrySelfDeleteUsesExternalLifecycleWithEntrySnapshotHeld) std::filesystem::remove_all(cwd); } -TEST(ThreadTools, HealthyThreadCanRepairBlockedInactiveThread) { +TEST_F(ThreadTools, HealthyThreadCanRepairBlockedInactiveThread) { const auto cwd = unique_cwd("repair_inactive"); const std::string cwd_string = cwd.string(); const std::string project_dir = @@ -350,7 +620,7 @@ TEST(ThreadTools, HealthyThreadCanRepairBlockedInactiveThread) { std::filesystem::remove_all(cwd); } -TEST(ThreadTools, HealthyThreadCanRepairBlockedActiveThreadAtQueueBoundary) { +TEST_F(ThreadTools, HealthyThreadCanRepairBlockedActiveThreadAtQueueBoundary) { const auto cwd = unique_cwd("repair_active"); const std::string cwd_string = cwd.string(); const std::string project_dir = diff --git a/tests/upgrade/macos_bundle_test.cpp b/tests/upgrade/macos_bundle_test.cpp index 321ed0fe1..5b911d6ea 100644 --- a/tests/upgrade/macos_bundle_test.cpp +++ b/tests/upgrade/macos_bundle_test.cpp @@ -1,6 +1,7 @@ #include "upgrade/macos_bundle.hpp" #ifdef __APPLE__ #include "upgrade/macos_app_installer.hpp" +#include #endif #include @@ -108,10 +109,69 @@ TEST(MacosBundleLayout, RejectsIncompleteAmbiguousAndSymlinkedApps) { } } +TEST(MacosBundleInstallPath, AcceptsRealArbitraryLocation) { + TempDir temp("acecode-macos-custom-location"); + const fs::path installed = fs::canonical(temp.path()) / + "My Tools" / "ACECode.app"; + create_bundle(installed); + std::string error; + EXPECT_TRUE(acecode::upgrade::macos_app_install_path_is_safe(installed, &error)) + << error; +} + +TEST(MacosBundleInstallPath, RejectsUnsafeLocations) { + TempDir temp("acecode-macos-unsafe-location"); + const fs::path root = fs::canonical(temp.path()); + for (const auto& relative : {"Other.app", "Outer.app/ACECode.app", + "Outer.APP/Tools/ACECode.app", + "AppTranslocation/id/d/ACECode.app"}) { + const fs::path installed = root / relative; + create_bundle(installed); + EXPECT_FALSE(acecode::upgrade::macos_app_install_path_is_safe(installed)) + << installed; + } + const fs::path installed = root / "real" / "ACECode.app"; + create_bundle(installed); + EXPECT_FALSE(acecode::upgrade::macos_app_install_path_is_safe({})); + EXPECT_FALSE(acecode::upgrade::macos_app_install_path_is_safe("ACECode.app")); + EXPECT_FALSE(acecode::upgrade::macos_app_install_path_is_safe(root / "ACECode.app")); + EXPECT_FALSE(acecode::upgrade::macos_app_install_path_is_safe( + root / "real" / "." / "ACECode.app")); + + std::error_code ec; + fs::create_directory_symlink(installed, root / "ACECode.app", ec); + if (ec) GTEST_SKIP() << "directory symlinks unavailable: " << ec.message(); + EXPECT_FALSE(acecode::upgrade::macos_app_install_path_is_safe(root / "ACECode.app")); + fs::create_directory_symlink(root / "real", root / "alias", ec); + ASSERT_FALSE(ec) << ec.message(); + EXPECT_FALSE(acecode::upgrade::macos_app_install_path_is_safe( + root / "alias" / "ACECode.app")); + fs::create_directories(root / "real" / "nested"); + create_bundle(root / "real" / "nested" / "ACECode.app"); + EXPECT_FALSE(acecode::upgrade::macos_app_install_path_is_safe( + root / "alias" / "nested" / "ACECode.app")); +} + #ifdef __APPLE__ -TEST(MacosBundleInstaller, UnsupportedInstallPathFailsWithoutMutation) { +TEST(MacosBundleInstaller, UnwritableParentFailsWithoutMutation) { + if (::geteuid() == 0) GTEST_SKIP() << "root bypasses directory permissions"; + TempDir temp("acecode-macos-read-only-parent"); + const fs::path parent = fs::canonical(temp.path()) / "Read Only"; + const fs::path installed = parent / "ACECode.app"; + create_bundle(installed); + fs::permissions(parent, fs::perms::owner_read | fs::perms::owner_exec); + std::string error; + const bool accepted = acecode::upgrade::preflight_macos_app_update( + installed, installed, "9.9.9", &error); + fs::permissions(parent, fs::perms::owner_all); + EXPECT_FALSE(accepted); + EXPECT_NE(error.find("cannot modify"), std::string::npos) << error; + EXPECT_TRUE(fs::is_directory(installed)); +} + +TEST(MacosBundleInstaller, ArbitraryInstallPathStillRequiresSignatureWithoutMutation) { TempDir temp("acecode-macos-install-preflight"); - const fs::path installed = temp.path() / "ACECode.app"; + const fs::path installed = fs::canonical(temp.path()) / "ACECode.app"; const fs::path candidate = temp.path() / "candidate" / "ACECode.app"; create_bundle(installed); create_bundle(candidate); @@ -120,9 +180,7 @@ TEST(MacosBundleInstaller, UnsupportedInstallPathFailsWithoutMutation) { std::string error; EXPECT_FALSE(acecode::upgrade::preflight_macos_app_update( installed, candidate, "9.9.9", &error)); - EXPECT_NE(error.find("~/Applications/ACECode.app"), std::string::npos); - EXPECT_NE(error.find("/Applications/ACECode.app"), std::string::npos); - EXPECT_NE(error.find("signed PKG"), std::string::npos); + EXPECT_NE(error.find("signature"), std::string::npos) << error; EXPECT_TRUE(fs::is_regular_file(installed / "sentinel.txt")); EXPECT_TRUE(fs::is_directory(candidate)); } diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index 779472b4d..db37a4983 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -6011,6 +6011,53 @@ TEST(WebServerHttp, PostMessageExpandsValidatedSessionReferences) { << "valid reference should preserve display text and expand model context"; } +TEST(WebServerHttp, RetryLastUserMessageValidatesRequestAndPreservesInput) { + WebServerFixture fx; + const auto created = cpr::Post(cpr::Url{fx.url("/api/sessions")}, + cpr::Header{{"Content-Type", "application/json"}}, cpr::Body{R"({})"}); + ASSERT_EQ(created.status_code, 201) << created.text; + const auto sid = json::parse(created.text)["session_id"].get(); + auto post = [&](const std::string& body) { + return cpr::Post(cpr::Url{fx.url("/api/sessions/" + sid + "/messages/retry")}, + cpr::Header{{"Content-Type", "application/json"}}, cpr::Body{body}); + }; + for (const auto& body : {"{", "[]", "{}", + R"({"expected_user_message_id":1})", R"({"expected_user_message_id":""})", + R"({"expected_user_message_id":"user-1","text":"new input"})"}) { + EXPECT_EQ(post(body).status_code, 400) << body; + } + const auto request = R"({"expected_user_message_id":"retry-http-user"})"; + EXPECT_EQ(post(request).status_code, 409); + auto entry = fx.registry->acquire(sid); + ASSERT_TRUE(entry); + auto provider = std::make_shared(); + provider->push_text("retried"); + provider->set_latency_ms(150); + install_test_provider(*entry, provider); + acecode::ChatMessage user; + user.role = "user"; + user.uuid = "retry-http-user"; + user.content = "original input"; + entry->loop->push_message(user); + entry->sm->on_message(user); + EXPECT_EQ(post(R"({"expected_user_message_id":"stale"})").status_code, 409); + const auto accepted = post(request); + ASSERT_EQ(accepted.status_code, 202) << accepted.text; + EXPECT_EQ(json::parse(accepted.text)["user_message_id"], user.uuid); + EXPECT_EQ(post(request).status_code, 409); + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (entry->loop->has_pending_work() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(5ms); + } + ASSERT_FALSE(entry->loop->has_pending_work()); + EXPECT_EQ(provider->turn_count(), 1); + const auto messages = entry->sm->load_active_messages(); + EXPECT_EQ(std::count_if(messages.begin(), messages.end(), [](const auto& message) { + return message.role == "user"; + }), 1); + EXPECT_EQ(post(request).status_code, 409); +} + TEST(WebServerHttp, TurnSteerValidatesIdentityAndCommitsAcceptedInput) { WebServerFixture fx; auto create = cpr::Post( diff --git a/web/public/vs-icons/Columns.svg b/web/public/vs-icons/Columns.svg new file mode 100644 index 000000000..8e175688a --- /dev/null +++ b/web/public/vs-icons/Columns.svg @@ -0,0 +1 @@ + diff --git a/web/scripts/check-session-loading-motion.py b/web/scripts/check-session-loading-motion.py new file mode 100644 index 000000000..4ee134a74 --- /dev/null +++ b/web/scripts/check-session-loading-motion.py @@ -0,0 +1,169 @@ +"""Check the built sidebar indicator with Playwright, including older Chromium. + +Run pnpm build first. Requires Python's playwright package and a Chromium install. +Optional: --browser PATH adds a legacy Chromium executable to the default browser. +""" + +import argparse +import json +import math +import subprocess +from html.parser import HTMLParser +from pathlib import Path + +from playwright.sync_api import sync_playwright + + +WEB = Path(__file__).resolve().parents[1] + + +class Styles(HTMLParser): + def __init__(self, source): + super().__init__() + self.active = False + self.parts = [] + self.feed(source) + + def handle_starttag(self, tag, attrs): + if tag == 'style': + self.active = True + + def handle_endtag(self, tag): + if tag == 'style': + self.active = False + + def handle_data(self, data): + if self.active: + self.parts.append(data) + + +def production_markup(): + # Render the actual component; the fixture does not duplicate its markup. + script = r''' +import fs from 'node:fs'; +import vm from 'node:vm'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { transformWithEsbuild } from 'vite'; +import clsx from 'clsx'; +const source = fs.readFileSync('src/components/Sidebar.jsx', 'utf8'); +const start = source.indexOf('function SessionAttentionIndicator'); +const end = source.indexOf('function SessionHoverCard', start); +if (start < 0 || end < start) throw new Error('Missing production indicator'); +const { code } = await transformWithEsbuild(source.slice(start, end), 'Indicator.jsx', { + loader: 'jsx', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment', +}); +const Component = vm.runInNewContext(`${code}; SessionAttentionIndicator`, { React, clsx }); +process.stdout.write(renderToStaticMarkup(React.createElement(Component, { + attention: 'in_progress', meta: { label: 'Running' }, +}))); +''' + return subprocess.run( + ['node', '--input-type=module'], input=script, cwd=WEB, + text=True, encoding='utf-8', capture_output=True, check=True, + ).stdout + + +SNAPSHOT = '''() => { + const host = document.querySelector('.ace-session-loading'); + const orbit = host.querySelector('.ace-session-loading-orbit'); + const dots = [...host.querySelectorAll('.ace-session-loading-dot')]; + const center = node => { const r = node.getBoundingClientRect(); return [r.x+r.width/2, r.y+r.height/2]; }; + return { centers:dots.map(center), hostCenter:center(host), + diameter:dots.map(node => parseFloat(getComputedStyle(node).width)), + slot:[host.offsetWidth, host.offsetHeight], opacity:parseFloat(getComputedStyle(orbit).opacity), + transforms:[orbit, ...dots].map(node => getComputedStyle(node).transform), + label:host.getAttribute('aria-label') }; +}''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--browser', action='append', default=[]) + parser.add_argument('--build-html', type=Path, default=WEB / 'dist/index.html') + parser.add_argument('--output', type=Path) + parser.add_argument('--screenshot-dir', type=Path) + args = parser.parse_args() + css = '\n'.join(Styles(args.build_html.read_text(encoding='utf-8')).parts) + assert css, 'Build HTML must contain the production CSS' + markup = production_markup() + results = [] + with sync_playwright() as playwright: + for executable in [None, *args.browser]: + options = {'headless': True} + if executable: + options['executable_path'] = executable + browser = playwright.chromium.launch(**options) + page = browser.new_page() + + def mount(theme='light', motion='no-preference', width=390): + page.set_viewport_size({'width': width, 'height': 240}) + page.emulate_media(reduced_motion=motion) + page.set_content(f'' + f'{markup}') + + for theme in ['light', 'dark']: + for width in [390, 1280]: + for motion in ['no-preference', 'reduce']: + mount(theme, motion, width) + frames = page.evaluate('''snapshot => { + const read = new Function('return (' + snapshot + ')')(); + const animations = document.querySelector('.ace-session-loading').getAnimations({subtree:true}); + animations.forEach(a => a.pause()); + const frames = []; + for (let time=0; time<=6480; time+=40) { + animations.forEach(a => a.currentTime=time); + frames.push(read()); + } + return frames; + }''', SNAPSHOT) + for frame in frames: + assert frame['slot'] == [16, 16], frame + assert len(frame['centers']) == 4 and frame['label'] == 'Running' + assert all(abs(d - 4.3) < 0.01 for d in frame['diameter']) + assert all(math.dist(frame['centers'][a], frame['centers'][b]) > 4.3 + for a in range(4) for b in range(a + 1, 4)) + centroid = [sum(c[axis] for c in frame['centers']) / 4 for axis in [0, 1]] + assert math.dist(centroid, frame['hostCenter']) < 0.02 + if motion == 'reduce': + assert all(frame['transforms'] == frames[0]['transforms'] for frame in frames) + opacity = [frame['opacity'] for frame in frames] + assert min(opacity) >= 0.599 and max(opacity) <= 1 + assert max(opacity) - min(opacity) > 0.39, 'Reduced motion must retain gentle running feedback' + else: + assert all(math.dist(a['centers'][0], b['centers'][0]) > 0.02 + for a, b in zip(frames, frames[1:])), 'Running motion must not stall' + results.append({'browser': browser.version, 'theme': theme, 'width': width, 'motion': motion, 'passed': True}) + + # Live preference changes must work without manually controlling animations. + mount() + for motion in ['no-preference', 'reduce', 'no-preference']: + page.emulate_media(reduced_motion=motion) + first = page.evaluate(SNAPSHOT) + page.wait_for_timeout(350) + last = page.evaluate(SNAPSHOT) + if motion == 'reduce': + assert first['transforms'] == last['transforms'] + assert abs(first['opacity'] - last['opacity']) > 0.01 + else: + assert first['transforms'] != last['transforms'] + + # Defined resting positions keep all four dots separate before animation starts. + mount() + page.add_style_tag(content='.ace-session-loading * { animation: none !important; }') + resting = page.evaluate(SNAPSHOT) + assert all(math.dist(c, resting['hostCenter']) > 3.4 for c in resting['centers']) + assert len({tuple(c) for c in resting['centers']}) == 4 + if args.screenshot_dir: + args.screenshot_dir.mkdir(parents=True, exist_ok=True) + page.locator('.ace-session-loading').screenshot( + path=str(args.screenshot_dir / f'indicator-{browser.version}.png')) + browser.close() + output = json.dumps(results, indent=2) + if args.output: + args.output.write_text(output + '\n', encoding='utf-8') + print(output) + + +if __name__ == '__main__': + main() diff --git a/web/src/components/ChangeReviewDetails.jsx b/web/src/components/ChangeReviewDetails.jsx index 04ae24426..2779d6941 100644 --- a/web/src/components/ChangeReviewDetails.jsx +++ b/web/src/components/ChangeReviewDetails.jsx @@ -361,7 +361,7 @@ export function ChangeReviewDetails({ aria-pressed={sideBySide} onClick={() => setSideBySide((value) => !value)} > - + - )} + {sidePanelListCollapsed && onToggleSidePanelList && ( + + )}
diff --git a/web/src/components/SettingsPage.jsx b/web/src/components/SettingsPage.jsx index c35bc37fe..8518693bd 100644 --- a/web/src/components/SettingsPage.jsx +++ b/web/src/components/SettingsPage.jsx @@ -3096,7 +3096,7 @@ function SectionUsage() { ]; return ( - <> +

使用情况