diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7fa725c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Protocol provenance verifies original file bytes on every platform. +Sources/CodexAdapter/Resources/Protocol/** -text +# Runtime notice provenance is bound to the original upstream bytes. +Vendor/SwiftWindowsRuntime/** -text +# Every platform archive must match the manifest stored at its release tag. +computer-mcp-plugin.toml -text diff --git a/.github/RELEASE.md b/.github/RELEASE.md new file mode 100644 index 0000000..e9b9005 --- /dev/null +++ b/.github/RELEASE.md @@ -0,0 +1,59 @@ +# Release publication and catalog notification + +Publish only an accepted plugin package bound to its reviewed source/tag and exact archive digest. +Keep an existing public tag and archive immutable. A candidate, draft or notification receipt does +not establish authenticated vendor or installed-host acceptance. + +The upload-release workflow promotes an accepted existing candidate into a matching draft; it does not make that draft public. After the accepted release becomes public, +`notify-catalog.yml` requests a complete catalog reconciliation. It also observes public edits, +channel promotion, unpublishing and deletion; those events never authorize catalog withdrawal by +themselves. The central publisher retains verified history and applies its reviewed withdrawal +policy. It verifies actual GitHub release sources rather than trusting an event payload. + +## Notification authority + +The workflow pins the website's central notification action to a reviewed full commit. Publish that +central commit before enabling a plugin workflow that references it. Review and update this pin +when adopting changes to the notification contract. The caller checks its immutable repository ID, +does not check out package code, and grants its own job token no repository permissions. + +Supply `CATALOG_DISPATCH_TOKEN` using existing reviewed authority with Actions write access to +`computer-mcp/computer-mcp.github.io` only. Website Contents write access is unnecessary. The action +can also receive an existing temporary token directly from a publishing job. Neither workflow +creates or persists credentials. Missing or rejected authority fails visibly; the +publisher's independent schedule still reconciles missed notifications. + +## Publication and retry + +A manual public release emits the release event. Publication performed with a repository's +`GITHUB_TOKEN` does not trigger ordinary release-event workflows. After that publication succeeds, +its automation must explicitly call this reusable workflow as a dependent job: + +```yaml +notify-catalog: + needs: publish + uses: ./.github/workflows/notify-catalog.yml + secrets: + CATALOG_DISPATCH_TOKEN: ${{ secrets.CATALOG_DISPATCH_TOKEN }} +``` + +Here `publish` is the job that actually makes the accepted release public, not the candidate-build +or draft-upload job. When using an existing short-lived token within that publishing job, invoke +the same pinned central action directly after publication instead. Keep token values out of command +arguments, printed output and release metadata. + +For an operator-driven publication or a missed/failed notification, explicitly dispatch: + +```sh +gh workflow run notify-catalog.yml --repo computer-mcp/plugin-codex --ref main +``` + +This schedules notification using its configured authority; it does not publish or rewrite a +release. Inspect the notification run and its returned central `run_url`. A successful dispatch +proves request acceptance only. Verify the central run completed successfully and the public index +contains the exact expected release identities and generation. If the release is already public and +notification fails, retry notification without changing or republishing the release. Complete +reconciliation is idempotent and repairs duplicate/missed events. + +See the central [catalog publication and notification contract](https://github.com/computer-mcp/computer-mcp.github.io/blob/main/docs/plugin-catalog.md) +for provenance, credentials, retry bounds and deployment semantics. diff --git a/.github/workflows/notify-catalog.yml b/.github/workflows/notify-catalog.yml new file mode 100644 index 0000000..f698f13 --- /dev/null +++ b/.github/workflows/notify-catalog.yml @@ -0,0 +1,28 @@ +name: Notify official plugin catalog +on: + release: + types: [published, edited, released, unpublished, deleted] + workflow_dispatch: + workflow_call: + secrets: + CATALOG_DISPATCH_TOKEN: + description: Existing receiver-scoped Actions write authority + required: true + outputs: + run_url: + description: Accepted central run; verify its deployment separately + value: ${{ jobs.notify.outputs.run_url }} +permissions: {} +jobs: + notify: + if: github.repository_id == '1368122608' + runs-on: ubuntu-latest + timeout-minutes: 3 + outputs: + run_url: ${{ steps.catalog.outputs.run-url }} + steps: + - name: Request complete catalog reconciliation + id: catalog + uses: computer-mcp/computer-mcp.github.io/.github/actions/notify-catalog@fc27dd0f370d028a3e5021e3585274891f696578 + with: + token: ${{ secrets.CATALOG_DISPATCH_TOKEN }} diff --git a/.github/workflows/upload-release.yml b/.github/workflows/upload-release.yml index 4246225..242789a 100644 --- a/.github/workflows/upload-release.yml +++ b/.github/workflows/upload-release.yml @@ -1,4 +1,4 @@ -name: Upload verified release archive +name: Upload verified release archives on: workflow_dispatch: inputs: @@ -25,7 +25,8 @@ jobs: SOURCE_RUN: ${{ inputs.source_run }} RELEASE_TAG: ${{ inputs.release_tag }} steps: - - name: Verify source and draft, then upload the existing archive + - name: Verify accepted source and commit-bound draft + id: source shell: bash run: | set -euo pipefail @@ -50,31 +51,60 @@ jobs: then .[0] else error("Expected one commit-bound draft release") end ' releases.json > release.json release_id=$(jq -r .id release.json) - gh run download "$SOURCE_RUN" --name "codex-plugin-ARM64-$source_sha" --dir artifact - cd artifact - expected=$(jq -r .archive_sha256 receipt.json) - [[ "$expected" =~ ^[0-9a-f]{64}$ ]] - printf '%s codex-plugin.zip\n' "$expected" | sha256sum --check --strict - unzip -t codex-plugin.zip - python3 - <<'PY' - import os, tomllib, zipfile - with zipfile.ZipFile("codex-plugin.zip") as archive: - manifest = tomllib.loads(archive.read("computer-mcp-plugin.toml").decode()) + echo "sha=$source_sha" >> "$GITHUB_OUTPUT" + echo "release_id=$release_id" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ steps.source.outputs.sha }} + persist-credentials: false + - name: Verify and promote every declared native archive + shell: bash + env: + SOURCE_SHA: ${{ steps.source.outputs.sha }} + RELEASE_ID: ${{ steps.source.outputs.release_id }} + run: | + set -euo pipefail + python3 - <<'PYTHON' + import json, os, re, tomllib + from pathlib import Path + manifest = tomllib.loads(Path("computer-mcp-plugin.toml").read_text()) if manifest["id"] != "codex" or "v" + manifest["version"] != os.environ["RELEASE_TAG"]: raise SystemExit("Release tag does not match the accepted plugin manifest") - PY - gh api "repos/$GH_REPO/releases/$release_id/assets" > assets.json - existing=$(jq -r '.[] | select(.name == "codex-plugin.zip") | .id' assets.json) - if [[ -n "$existing" ]]; then - jq -e --argjson id "$existing" --arg digest "sha256:$expected" ' - .[] | select(.id == $id) | - .state == "starter" or (.state == "uploaded" and .digest == $digest) - ' assets.json > /dev/null - if jq -e --argjson id "$existing" '.[] | select(.id == $id) | .state == "uploaded"' assets.json > /dev/null; then - exit 0 + names = [item["name"] for item in manifest["compatibility"]["artifacts"]] + if not names or len(set(names)) != len(names) or any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*\.zip", name) for name in names): + raise SystemExit("Expected unique declared native archives") + Path("archive-names.json").write_text(json.dumps(names)) + PYTHON + while IFS= read -r archive; do + name=${archive%.zip} + gh run download "$SOURCE_RUN" --name "$name-$SOURCE_SHA" --dir "artifacts/$name" + python3 Scripts/check-package.py --archive "artifacts/$name/$archive" --receipt "artifacts/$name/receipt.json" > "artifacts/$name/verification.json" + done < <(jq -r '.[]' archive-names.json) + # Upload only after every declared archive passes its exact source-bound check. + upload_asset() { + local file=$1 name=$2 content_type=$3 expected existing + expected=$(sha256sum "$file" | cut -d' ' -f1) + gh api --paginate "repos/$GH_REPO/releases/$RELEASE_ID/assets?per_page=100" --slurp | jq 'add' > assets.json + existing=$(jq -er --arg name "$name" '[.[] | select(.name == $name)] | if length <= 1 then (.[0].id // "") else error("Duplicate release asset") end' assets.json) + if [[ -n "$existing" ]]; then + jq -e --argjson id "$existing" --arg digest "sha256:$expected" ' + .[] | select(.id == $id) | + .state == "starter" or (.state == "uploaded" and .digest == $digest) + ' assets.json > /dev/null + if jq -e --argjson id "$existing" '.[] | select(.id == $id) | .state == "uploaded"' assets.json > /dev/null; then + return + fi + gh api "repos/$GH_REPO/releases/$RELEASE_ID" | jq -e --arg tag "$RELEASE_TAG" --arg sha "$SOURCE_SHA" '.draft and .tag_name == $tag and .target_commitish == $sha' > /dev/null + gh api --method DELETE "repos/$GH_REPO/releases/assets/$existing" fi - gh api --method DELETE "repos/$GH_REPO/releases/assets/$existing" - fi - gh api --method POST "https://uploads.github.com/repos/$GH_REPO/releases/$release_id/assets?name=codex-plugin.zip" \ - --input codex-plugin.zip -H 'Content-Type: application/zip' > uploaded.json - jq -e --arg digest "sha256:$expected" '.state == "uploaded" and .digest == $digest' uploaded.json > /dev/null + # Re-read immediately before upload; published releases are immutable. + gh api "repos/$GH_REPO/releases/$RELEASE_ID" | jq -e --arg tag "$RELEASE_TAG" --arg sha "$SOURCE_SHA" '.draft and .tag_name == $tag and .target_commitish == $sha' > /dev/null + gh api --method POST "https://uploads.github.com/repos/$GH_REPO/releases/$RELEASE_ID/assets?name=$name" \ + --input "$file" -H "Content-Type: $content_type" > uploaded.json + jq -e --arg digest "sha256:$expected" '.state == "uploaded" and .digest == $digest' uploaded.json > /dev/null + } + while IFS= read -r archive; do + name=${archive%.zip} + upload_asset "artifacts/$name/$archive" "$archive" application/zip + upload_asset "artifacts/$name/receipt.json" "$name.receipt.json" application/json + done < <(jq -r '.[]' archive-names.json) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 37f32c2..8f40675 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -4,13 +4,28 @@ on: push: branches: [main] workflow_dispatch: + inputs: + windows_sdk_revision: + description: 'Expected locked swift-codex commit for focused Windows checks; empty runs native packaging on both platforms' + required: false + type: string + windows_validation_scope: + description: 'Windows candidate checks; all remains the complete adapter gate' + type: choice + options: [all, host-mcp, database, artifact] + default: all + windows_artifact_run: + description: 'Completed source audit run whose exact linked Windows binary should be checked' + required: false + type: string permissions: contents: read concurrency: - group: validate-${{ github.ref }} + group: validate-${{ github.ref }}-${{ inputs.windows_validation_scope || 'all' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: package: + if: ${{ inputs.windows_sdk_revision == '' }} runs-on: macos-latest timeout-minutes: 30 steps: @@ -35,6 +50,8 @@ jobs: /usr/bin/swift package resolve git diff --exit-code -- Package.resolved Scripts/verify-swift-codex-release-gate.sh + node --test Tests/schema-import.test.mjs + node Scripts/import-schema.mjs --check /usr/bin/swift format lint --strict --recursive Package.swift Sources Tests /usr/bin/swift build --build-tests --disable-automatic-resolution - name: Run Swift tests @@ -61,12 +78,227 @@ jobs: - name: Validate package ownership and publication run: python3 -m unittest discover -s Tests -p 'test_*.py' -v - name: Build relocatable adapter and receipt - run: python3 Scripts/package.py --output dist --configuration release + run: | + python3 Scripts/package.py --output dist --configuration release + python3 Scripts/check-package.py --archive dist/codex-plugin-macos-arm64.zip --receipt dist/receipt.json --destination .build/accepted-package + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: codex-plugin-macos-arm64-${{ github.sha }} + path: | + dist/codex-plugin-macos-arm64.zip + dist/receipt.json + if-no-files-found: error + retention-days: 14 + + windows-package: + if: ${{ inputs.windows_sdk_revision == '' }} + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.13' + - uses: compnerd/gha-setup-swift@bbae8ce86bac5a3449f3992b3d88bc4ca25f2057 + with: + swift-version: swift-6.2.3-release + swift-build: 6.2.3-RELEASE + - name: Validate native package ownership and inputs + shell: pwsh + run: | + python Scripts/version.py check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + foreach ($pattern in @('test_package*.py', 'test_windows*.py', 'test_protocol*.py', 'test_version.py')) { + python -m unittest discover -s Tests -p $pattern -v + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + swift package resolve + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + git diff --exit-code -- Package.resolved + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build the native archive and runtime receipt + shell: pwsh + run: | + python Scripts/package.py --output dist --configuration release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + git diff --exit-code -- Package.resolved computer-mcp-plugin.toml + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: codex-plugin-${{ runner.arch }}-${{ github.sha }} + name: codex-plugin-windows-x86_64-${{ github.sha }} path: | - dist/codex-plugin.zip + dist/codex-plugin-windows-x86_64.zip dist/receipt.json if-no-files-found: error retention-days: 14 + + windows-package-acceptance: + needs: windows-package + runs-on: windows-2022 + timeout-minutes: 15 + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.13' + - name: Download the exact package from this run + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh run download $env:GITHUB_RUN_ID --repo $env:GITHUB_REPOSITORY --name "codex-plugin-windows-x86_64-$env:GITHUB_SHA" --dir accepted-artifact + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Check relocated package with app-local runtime + shell: pwsh + run: ./Scripts/check-windows-package.ps1 -ArtifactDirectory accepted-artifact -OutputDirectory .build/windows-package-acceptance + - name: Preserve exact archive acceptance + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: codex-plugin-windows-package-acceptance-${{ github.run_id }}-${{ github.run_attempt }} + path: .build/windows-package-acceptance/evidence/ + include-hidden-files: true + if-no-files-found: error + + windows-candidate: + if: ${{ inputs.windows_sdk_revision != '' && inputs.windows_validation_scope != 'host-mcp' && inputs.windows_validation_scope != 'artifact' }} + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: compnerd/gha-setup-swift@bbae8ce86bac5a3449f3992b3d88bc4ca25f2057 + with: + swift-version: swift-6.2.3-release + swift-build: 6.2.3-RELEASE + - name: Audit native dependencies and complete adapter source + shell: pwsh + env: + CODEX_CANDIDATE_REVISION: ${{ inputs.windows_sdk_revision }} + WINDOWS_VALIDATION_SCOPE: ${{ inputs.windows_validation_scope }} + run: | + $databaseOnly = $env:WINDOWS_VALIDATION_SCOPE -eq 'database' + ./Scripts/validate-windows-candidate.ps1 -SDKRevision $env:CODEX_CANDIDATE_REVISION -DatabaseOnly:$databaseOnly + - name: Preserve Windows source audit + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: codex-plugin-windows-source-${{ github.run_id }}-${{ github.run_attempt }} + path: .build/windows-candidate/evidence/ + include-hidden-files: true + if-no-files-found: error + + windows-process: + if: ${{ inputs.windows_sdk_revision != '' && (inputs.windows_validation_scope == '' || inputs.windows_validation_scope == 'all') }} + runs-on: windows-2022 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Validate exact SDK input + shell: pwsh + env: + CODEX_CANDIDATE_REVISION: ${{ inputs.windows_sdk_revision }} + run: | + if ($env:CODEX_CANDIDATE_REVISION -cnotmatch '^[0-9a-f]{40}$') { + throw 'Windows process acceptance requires an exact SDK commit' + } + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: swift-library/swift-codex + ref: ${{ inputs.windows_sdk_revision }} + path: .build/windows-process-sdk + persist-credentials: false + - uses: compnerd/gha-setup-swift@bbae8ce86bac5a3449f3992b3d88bc4ca25f2057 + with: + swift-version: swift-6.2.3-release + swift-build: 6.2.3-RELEASE + - name: Test exact production process sources in debug and release + shell: pwsh + run: ./Scripts/test-windows-process.ps1 -SDKPath .build/windows-process-sdk -OutputDirectory .build/windows-process + - name: Preserve native process acceptance + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: codex-plugin-windows-process-${{ github.run_id }}-${{ github.run_attempt }} + path: .build/windows-process/evidence/ + include-hidden-files: true + if-no-files-found: error + + windows-host-mcp: + if: ${{ inputs.windows_sdk_revision != '' && inputs.windows_validation_scope != 'database' && inputs.windows_validation_scope != 'artifact' }} + runs-on: windows-2022 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - name: Validate exact SDK input + shell: pwsh + env: + CODEX_CANDIDATE_REVISION: ${{ inputs.windows_sdk_revision }} + run: | + if ($env:CODEX_CANDIDATE_REVISION -cnotmatch '^[0-9a-f]{40}$') { + throw 'Host MCP acceptance requires an exact SDK commit' + } + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: swift-library/swift-codex + ref: ${{ inputs.windows_sdk_revision }} + path: .build/windows-host-sdk + persist-credentials: false + - uses: compnerd/gha-setup-swift@bbae8ce86bac5a3449f3992b3d88bc4ca25f2057 + with: + swift-version: swift-6.2.3-release + swift-build: 6.2.3-RELEASE + - name: Test inherited host pipes with the shipping MCP dependency + shell: pwsh + run: ./Scripts/test-windows-host-mcp.ps1 -SDKPath .build/windows-host-sdk -OutputDirectory .build/windows-host-mcp + - name: Preserve native host MCP acceptance + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: codex-plugin-windows-host-mcp-${{ github.run_id }}-${{ github.run_attempt }} + path: .build/windows-host-mcp/evidence/ + include-hidden-files: true + if-no-files-found: error + + windows-artifact: + if: ${{ inputs.windows_validation_scope == 'artifact' && inputs.windows_sdk_revision != '' }} + runs-on: windows-2022 + timeout-minutes: 15 + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: compnerd/gha-setup-swift@bbae8ce86bac5a3449f3992b3d88bc4ca25f2057 + with: + swift-version: swift-6.2.3-release + swift-build: 6.2.3-RELEASE + - name: Check the exact linked native adapter + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + SOURCE_RUN: ${{ inputs.windows_artifact_run }} + SDK_REVISION: ${{ inputs.windows_sdk_revision }} + run: ./Scripts/check-windows-artifact.ps1 -SourceRun $env:SOURCE_RUN -SDKRevision $env:SDK_REVISION + - name: Preserve native artifact acceptance + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: codex-plugin-windows-artifact-${{ github.run_id }}-${{ github.run_attempt }} + path: .build/windows-artifact/evidence/ + include-hidden-files: true + if-no-files-found: error diff --git a/Documentation/Architecture/Package.md b/Documentation/Architecture/Package.md index a399e18..ed9ef84 100644 --- a/Documentation/Architecture/Package.md +++ b/Documentation/Architecture/Package.md @@ -1,6 +1,8 @@ # Package -The package targets macOS 14 and Swift tools 6.2. `CodexMCPAdapter` owns argument +The package uses Swift tools 6.2 and supports macOS 14 and Windows x86_64. +Windows archives use the reviewed Swift 6.2.3 runtime and pinned SQLite static +build. `CodexMCPAdapter` owns argument handling; `CodexAdapter` owns configuration, MCP projection and Codex domain implementation. Runtime tests live in `CodexAdapterTests`; command parsing tests live in `CodexMCPAdapterTests`. Computer MCP Core is not a @@ -10,10 +12,12 @@ dependency. | Package | Responsibility | | --- | --- | -| Official MCP Swift SDK 0.12.1 | Standard northbound MCP transport, tools and results | +| `computer-mcp/swift-sdk` transport fork | Standard northbound MCP transport, tools and results; native Windows stdio and complete POSIX frame writes | | swift-codex | App Server client and Exec client, each with its own lifecycle | | swift-subprocess 0.4.0 | Existing process infrastructure dependency | | swift-argument-parser 1.8.2 | Named options, schema-comparison subcommand, validation and generated CLI help | +| Apple Swift System 1.8.1 | Typed CRT descriptors for inherited Windows MCP pipe handles; already shared by the MCP dependency | +| Apple Swift Crypto 4.5.2 | SHA-256 on Windows; Apple platforms retain CryptoKit | | GRDB 7.11.1 | Codex approval, runtime/thread ownership, acceptance run, worktree lease and managed-worktree storage and transactions | `Scripts/verify-swift-codex-release-gate.sh` verifies that the manifest's exact @@ -30,6 +34,73 @@ protocol initialization and request correlation. Domain ownership, input validat Typed validation checks known native approval and interaction contracts; the SDK sends the original JSON response, preserving unknown fields. +The SDK's adoption resource determines the native request inventory. The +plugin derives complete MCP input schemas and validates requests with the +SDK's generated stable or experimental `ClientRequest` type before sending +the original JSON through the SDK connection. `CodexAppServerMethodCatalog` +owns operation-risk classification; an adopted request without a policy fails +catalog construction and CI. Stable native tools use `codex.app.native.*`; +experimental requests require the explicit experimental call path. Protocol +inspection remains separate from execution admission. + +Interactive commands, processes, filesystem watches and MCP event streams +retain handles bound to the exact connection generation. Follow-up calls +cannot use another generation's handles. Uncertain sends retain reservations; +only successful terminal responses, matching process-exit events or confirmed +process-group cleanup release them. A supervisor's exit alone is not cleanup +evidence. Late notifications and server requests from retired connections do +not modify the active connection's state. Each reservation captures its creating +work-invocation UUID; follow-up requests retain that origin. Its reservation token +identifies the ownership lifetime separately from the reusable native handle. +Late replies cannot release or change a replacement reservation's state. +Confirmed process-group cleanup also clears that generation's loaded, +subscribed and active-thread claims and connection-local thread cache. Unknown +cleanup retains these claims until confirmation; native history and persistent +thread-ownership records are separate from a live connection's claims. + +Thread and turn work capture their creating invocation separately. Native IDs +can arrive through notifications before the creating RPC returns; pending +inputs and approvals share the same once-bound origin. An ordinary client's +unbound work cannot later be claimed by a metadata-bearing reader or resume. +Goal-created turns and callbacks retain the Goal's origin. Interleaved Goal +notifications invalidate older observations before ownership can be released. +The SDK's ordered raw inbound stream lets the adapter register a server request +before applying subsequent completion notifications. Registered handlers run +separately, so a slow host callback cannot hide later requests from the work +ledger. Admission rejects requests beyond the pending-handler capacity while +retaining active and uncertain owners. +Server-request work belongs to its exact connection and remains live until both +the SDK request lifecycle and any host callback have settled. Native process +cleanup alone does not finish a host callback, and a new connection can reuse +native request IDs without replacing callbacks from the previous connection. + +Inline and detached reviews and explicitly started queued submissions bind +their returned turn identities to the creating invocation. Early input shares +that binding, and a completion observed before the reply prevents resurrection. +Unsubscribe acknowledgements remove subscriptions without declaring native work +finished. Missing entries in a complete loaded-thread listing retain uncertain +ownership until closure; partial pages never replace the complete known set. +Native archive, delete and revert may report closure after a shutdown timeout, +so those operations retain their previous thread owner until process cleanup. +Graceful handoff cannot reap a runtime that still owns unrelated native handles, +login attempts, callbacks or other thread activity. + +Account and MCP OAuth login attempts outlive their initiating RPCs. Account +completion matches the exact login ID, including completion before the reply; +cancel or replacement acknowledgements do not release unfinished attempts. +MCP login completion matches both server name and optional thread identity. +Because that notification carries no attempt ID, overlapping attempts for the +same scope are rejected until completion. Confirmed process cleanup releases +only the affected generation's login work. + +Realtime sessions use the same admission and completion ownership boundary. +A stop acknowledgement does not replace the matching closed notification. +Remote-control initialization is retained until its initial status is known. +Persisted native remote control belongs to the process creator; an explicit +enable keeps its own invocation. Native disabled status only changes desired +connectivity, so an enabled lifetime remains uncertain until process cleanup. +These states prevent idle handoff from terminating unrelated background work. + ## Execution and authority `CodexAppServerProvider` maps the App Server and persisted-domain tools to @@ -42,6 +113,54 @@ from its bounded event history. MCP discovery does not eagerly connect any provider. The server invokes all shutdown paths when its northbound transport completes or fails. +Every advertised tool declares the ordinary MCP work resource at +`computer-mcp://runtime/work/v1`. Reads combine Exec and App Server owners into +one bounded snapshot with a per-server instance UUID and a monotonically +increasing revision. Unchanged resources keep the same revision. Missing origins, +invalid identities, duplicate resources, unavailable providers and capacity +overflow fail the whole observation; no partial or truncated work set is +published. One read runs at a time. An overlapping reader receives a retryable +error instead of a snapshot begun before its call. Shutdown joins the owned +observation before shutting down providers. Discovery and reads start no native +provider process. + +Work rows may expose bounded `handles` containing the native identifiers needed +for continuation. The primary `id` identifies an acquired lifetime, so reuse of +a native handle produces a different primary ID. App Server rows include +`runtime_id`; thread, turn, queue, login, request and approval aliases come from +their existing lifecycle records. Late acknowledgements can add identifiers +without changing the acquisition. Commands, processes, filesystem watches and +event subscriptions expose `native_id`; Exec sessions use their primary ID. +Aliases preserve string versus exact integer identity, remain through uncertain +cleanup and confer no permission. Whole-report resource and byte bounds also +apply to aliases. + +Existing-handle tools declare ordinary MCP continuation selectors. Native tool +selectors use the SDK-derived thread parameter schema and the adapter's owned +command/process/watch/subscription lifetimes. `methods.call` declares exact method +conditions for the same associations; a handle-creation operation does not select +an older lifetime merely because the caller reused its native ID. Optional native +thread scopes support an explicit null. New tasks, including forks and Exec +resume processes, use their creation context. Unscoped status, listing, events +and whole-runtime operations have no implicit existing-owner selector. Runtime +inspect/stop tools can locate a specific `runtime_id` while it owns work. +Declarations preserve publisher risk metadata and native schemas. Host generation +selection and current authorization remain host responsibilities. + +Exec reserves a session identity before native startup. Its owner retains the +startup task until registration settles, counts that pending identity once +against capacity, and cancels and joins it during shutdown. A handle returned +after cancellation is still registered and cleaned through the normal process +waiter. Shutdown closes new admission; cancellation never substitutes for +observed process cleanup. + +Each Exec owner captures its optional work-invocation UUID before startup. The +pending launch and registered session share that immutable acquisition; later +reads or cancellation calls cannot replace it. A missing binding is distinct +from an empty work set. Result retention remains owned after process termination. +`codex.exec.release` drops a settled result only after confirmed process cleanup, +without changing native conversation storage or the thread-owner index. + `CodexLaunchContext` records the initial workspace and verified host subject. The host authorizes every invocation against its current policy; launch metadata does not freeze permissions or grant control-plane access. Native Codex @@ -52,6 +171,10 @@ through unchanged. An initial directory or Git worktree is not OS isolation. `CodexProcessEnvironment` preserves vendor state configuration and proxy behavior while removing parent Codex session and Computer MCP launch metadata. External Codex installation and user credentials remain user-owned. +Environment filtering uses native case-insensitive name comparison on Windows. +Proxy settings preserve the existing spelling and do not create case aliases; +ambiguous caller-supplied names remain subject to native launch validation. +Apple platforms retain distinct uppercase/lowercase proxy variables. App Server and Exec resolve the configured executable using the same launch environment and workspace: absolute paths are direct, relative paths @@ -150,3 +273,20 @@ Universal 2 support. The package job neither installs vendor executables nor exercises real accounts. An authorized publisher must separately review provenance, signing and runtime acceptance before promoting an artifact to a public release. + +## Inherited host channel + +The host binds callbacks to its own immutable caller/workspace scope. macOS uses +an inherited connected Unix socket. The Windows transport accepts a paired read +and write byte-pipe handle through `COMPUTER_MCP_HOST_READ_HANDLE` and +`COMPUTER_MCP_HOST_WRITE_HANDLE`, alongside the host context. Handles must be +canonical decimal values, distinct, inherited, and separate from standard I/O. +Ambiguous environment keys, partial pairs, files and message pipes are rejected. + +Admission clears inheritance and bridges owned duplicates through Swift System's +CRT descriptor type. Once the MCP transport has duplicated the handles for native +I/O, the adapter closes the inherited originals and CRT intermediates. The owning +MCP transport supplies framing, bounded queues, cancellation and native I/O joins; +the adapter retains one connection/close task. Callback metadata is removed from +vendor environments. Windows native transport evidence is separate from a complete +host launch, adapter artifact or authenticated model acceptance. diff --git a/Documentation/Architecture/VersioningAndRelease.md b/Documentation/Architecture/VersioningAndRelease.md index f444761..d4d2df5 100644 --- a/Documentation/Architecture/VersioningAndRelease.md +++ b/Documentation/Architecture/VersioningAndRelease.md @@ -36,7 +36,10 @@ installed-gateway checks against the exact adapter bytes. Accept the complete host/plugin/SDK combination before delivery. Create a formal signed tag only for the accepted commit; `upload-release.yml` promotes the already-built artifact from its verified source run into the matching draft. -The upload also verifies the archive's manifest against the formal tag. +The upload verifies every platform archive's inventory and manifest against the +formal tag before uploading any asset. It promotes both declared native archives +and their receipts from the same successful source run without rebuilding. +An already uploaded asset must have the same digest; conflicting bytes fail. Candidate retries keep the intended product version and use a new run identity. A public tag and archive remain immutable. Only changed components are released. See [Installation](../Reference/Installation.md) for packaging, installation, diff --git a/Documentation/Reference/HostIntegration.md b/Documentation/Reference/HostIntegration.md index d490738..0de4a94 100644 --- a/Documentation/Reference/HostIntegration.md +++ b/Documentation/Reference/HostIntegration.md @@ -28,6 +28,37 @@ native request. The host decides whether the caller may invoke Codex; it does not replace Codex's sandbox or approval policy. Codex callbacks into host tools remain subject to current host authorization and confirmation. +Execution tool metadata declares `io.github.computer-mcp/risk`. Starting or +continuing a model task, answering approvals or interactive requests, and +native lifecycle operations that can invoke configured command hooks declare +`full-shell`. This includes thread startup/resume/fork, turn steering, manual +compaction, archive/delete and turn interruption. Setting an active Goal, +injecting model-visible history, releasing an elicitation hold, queued and +realtime inputs, and native execution-policy updates use the same floor because +they can start or steer continued work. Explicit native sandbox +settings do not lower the floor: the host does not enforce a vendor sandbox. +The generic `codex.app.methods.call` also declares `full-shell` because its +selected method can execute arbitrary commands. Hosts use these declarations as a +minimum risk, never to reduce a configured restriction or grant access. +`codex.app.methods.list` provides the same per-method classification for +configuration review. MCP annotations remain advisory. + +The three private host-service consumers also declare +`io.github.computer-mcp/host-action`: diagnostics uses `diagnostics.snapshot`, +provisioning uses `workspaces.provision`, and removal uses `workspaces.remove`. +The host binds the recognized effect to the admitted invocation and operation +ticket together with the exact arguments. The declaration grants no authority; +the host enforces its minimum risk and rejects a changed effect before dispatch. +Released hosts that recognize the original Codex method names remain compatible. +Codex's persisted worktree IDs retain their existing identity on either host. + +Metadata/history/event inspection declares `read-only`. Narrow filesystem and +metadata mutations retain their effect classification. Exec cancellation and +native process termination declare `destructive`; they retire owned work rather +than submit new model input. Native turn interruption differs because Codex can +run an Interrupt command hook. A host emergency-revocation action is a separate +host-owned control, not a promise that a vendor cancellation RPC has no hooks. + Native approval responses use official response objects, including session scope, amendments, refusal and cancellation. Responses bind to their original SDK server request and can be consumed only once. Host destructive operations diff --git a/Documentation/Reference/Installation.md b/Documentation/Reference/Installation.md index 096396a..331d641 100644 --- a/Documentation/Reference/Installation.md +++ b/Documentation/Reference/Installation.md @@ -1,7 +1,9 @@ # Installation and recovery -The package owns `bin/codex-mcp-adapter` and its adjacent -`codex-plugin_CodexAdapter.bundle`. Codex itself remains an external dependency. +On macOS the package owns `bin/codex-mcp-adapter` and its adjacent +`codex-plugin_CodexAdapter.bundle`. On Windows it owns +`bin/codex-mcp-adapter.exe`, `codex-plugin_CodexAdapter.resources` and the required +runtime DLLs in `bin/`. Codex itself remains an external dependency. Installing this package never installs, updates or removes Codex, changes global PATH, or grants a profile access to tools. @@ -16,28 +18,45 @@ python3 Scripts/package.py --output /absolute/new/artifact-directory The default build is release; `--configuration debug` is available for development. The destination must not exist. The script builds using the pinned dependency -resolution, copies the executable and resource bundle, collects upstream license -and notice files, applies an ad-hoc signature, and emits `codex-plugin.zip` and -`receipt.json`. The receipt records each file and archive SHA-256, architecture, -and build configuration. It is not an official-source or Developer ID signature. +resolution, copies the executable and resources, collects upstream license and +notice files, and emits the native archive and `receipt.json`. macOS binaries +receive an ad-hoc signature; Windows binaries are unsigned. The receipt records +each file and archive SHA-256, platform, architecture and build configuration. +It is not an official-source or publisher signature. No artifact is uploaded or installed automatically. -The repository manifest declares the archive's supported architectures. Packaging -requires the built adapter's slices to match that declaration and preserves the -manifest bytes exactly. Official GitHub installation verifies the archive's -manifest against the declaration at its release tag. The published archive -targets arm64. +The repository manifest declares `codex-plugin-macos-arm64.zip` and +`codex-plugin-windows-x86_64.zip`. Packaging requires the built adapter to match +exactly one declared platform and architecture combination. Both archives carry +the same manifest bytes; its `platform_paths` selects the native executable. +Computer MCP 1.3.0 or newer understands these declarations. Official GitHub +installation verifies the archive manifest against its release tag. + +On Windows, run the packaging command with Python 3.11 or newer, PowerShell, +Swift 6.2.3, `clang-cl`, `llvm-lib` and `llvm-readobj` available. The packager +builds SQLite from the checksummed source in `Scripts/windows-sqlite.json`, +verifies its required features, and passes the resulting headers and static +library to SwiftPM. It copies the recursively inspected Swift runtime DLLs +beside the adapter and verifies their architecture and notice coverage. The +Windows receipt records each DLL's source digest and the SQLite build identity. +See [third-party components](../../THIRD_PARTY_NOTICES.md) for distribution terms. Package inputs must be regular files and directories. Symbolic links and special -files are rejected before signing or running the staged adapter. The output is +files, including Windows reparse points, are rejected before signing or running +the staged adapter. The output is published atomically without replacing any existing destination, including an empty directory created while the build is running. Failure removes only the packager's temporary staging directory; existing outputs remain unchanged. The repository's `Validate and package` workflow runs on pull requests, pushes and manual dispatch. It checks formatting, tests and the dependency lock, then -retains the ZIP and receipt as downloadable workflow artifacts. These outputs -are ad-hoc signed validation builds, not published or verified official releases. +retains both native ZIPs and receipts as downloadable workflow artifacts. A +separate Windows job installs no Swift toolchain, relocates the exact ZIP and +runs the adapter with system-only child PATH. It records actual loaded module +paths and digests, MCP discovery, reconnect and joined native process cleanup. +Hosted runners can contain preinstalled software; this gate does not claim a +pristine Windows installation or authenticated model execution. These outputs +are validation builds, not published or verified official releases. Check the receipt's architecture before installation. Public distribution needs separate publisher authorization and its signing/provenance review. @@ -48,7 +67,7 @@ the current revision from `computer-mcp plugins list`, then supply the digest from the artifact receipt: ```sh -computer-mcp plugins install /absolute/path/codex-plugin.zip --id codex --version 0.2.0 --sha256 DIGEST --expected-revision REVISION +computer-mcp plugins install /absolute/path/ARCHIVE.zip --id codex --version VERSION --sha256 DIGEST --expected-revision REVISION ``` The new package is disabled and exposes no tools. In its settings, choose the @@ -96,10 +115,12 @@ boundary when serving through Computer MCP. ## Stop, update, roll back and remove -Before changing a plugin, finish or explicitly cancel its active execution, -then disconnect its Gateway -clients. The host rejects registration changes while clients are connected; -it does not interrupt their work to force an update. +Configuration and installation changes publish a new runtime generation. +Existing work retains its creating runtime and package files until its owner +confirms completion; new work uses the current configuration. Current grants +and workspace access are checked on each invocation. Disabling a plugin closes +admission for new work while preserving the ownership needed to inspect or +cancel existing work. Explicitly cancel work when it should stop. Install an updated artifact through the same command. Saved arguments, exposure and profile grants are not reset. Select a retained installation with `plugins diff --git a/Documentation/Reference/Workflows.md b/Documentation/Reference/Workflows.md index 53739c9..f6bfb47 100644 --- a/Documentation/Reference/Workflows.md +++ b/Documentation/Reference/Workflows.md @@ -13,13 +13,13 @@ by the runtime; do not substitute upstream thread IDs for local handles. | Acceptance and writer ownership | `codex.run.*` and `codex.worktree.leases.*`, with durable revisions | | Managed worktrees | `codex.worktree.provision.plan` / `perform`, `managed.list` / `read`, and `remove.plan` / `perform`; mutations require the host workspace service | | Operational diagnostics | `codex.diagnostics.snapshot`, with optional bounded `limit` | -| Exec | `codex.exec.start` or `resume` → `list` / `events` / `result` → `cancel` when required | +| Exec | `codex.exec.start` / `resume`; inspect `list` / `events` / `result`; `cancel` while running; `release` after cleanup | ## Native coding configuration Exec start accepts `prompt` and optional `model` and `options`; resume accepts `upstream_session_id` and optional `prompt`, `model` and `options`. -Exec events/result/cancel use `session_id`. +Exec events/result/cancel/release use `session_id`. Exec uses existing Codex configuration and authentication, including provider, MCP servers, Skills and hooks. Omitted sandbox and approval values inherit native @@ -39,11 +39,38 @@ inspect vendor configuration and available models. Thread and turn parameters may explicitly select native sandbox, approval policy and directory; unsupported vendor inputs return a vendor error, never a silent downgrade. +Use `codex.app.native.` for each adopted stable request, replacing `/` +with `.` in the native method name. Pass native arguments under `params`; +parameterless methods take an empty tool-argument object. The tool schema +includes every native parameter and its referenced definitions. SDK request +types validate required fields and known value shapes; original extension +fields pass through. Responses retain the usual `structuredContent.result` +envelope and notifications appear in `codex.app.events.read`. Signed 64-bit +JSON integers remain exact through MCP and App Server transport; integers +outside that range fail explicitly. + +`codex.app.methods.describe` returns a request's full parameter schema, risk +and stability channel. Experimental requests use `codex.app.methods.call` +with `experimental: true` and require the runtime's `experimental_api` setting. +SDK-excluded lifecycle/internal methods are not executable through this path. +Native account-token refresh and device-attestation callbacks require an +external credential or attestation owner; the adapter rejects those callbacks +explicitly instead of inventing credentials or proof. + +Native turn starts and other thread execution paths check existing worktree +leases. Use the higher-level leased-turn workflow when a lease is active. +Interactive command/process, filesystem-watch and event-stream handles belong +to the connection that created them. A replacement connection cannot operate +an old handle. A failed stop or uncertain request is not proof of cleanup. +Runtime status reports `cleanup-pending` when owned process-group cleanup is +unconfirmed; new connection admission remains closed until a later check +confirms that group is gone. + The configured executable can be an absolute path, a path relative to the workspace, or a name on the child process PATH. Resolution and launch share the same environment. Missing programs fail explicitly without loading shell profiles. -Exec event reads acceptExec event reads accept `after_cursor` (default 0) and `max_results` +Exec event reads accept `after_cursor` (default 0) and `max_results` (default 100, range 1–1000). Bounded event history reports missed rows; clients must not treat an evicted cursor as a complete history. @@ -52,6 +79,11 @@ Successful results preserve the existing JSON text and `isError: true`; unknown tools are MCP protocol errors. Inspect actual catalog schemas for the complete input contract. +Retained events and approval records redact credentials before storage. Known +native token-usage and Goal counters preserve their integer or null values. +Usage containers still receive recursive credential redaction; a string in a +counter field is not treated as a public measurement. + Host permissions are checked on each invocation, including callbacks. MCP disconnection shuts down each owned provider. Finish or cancel active work before changing the plugin registration. Each runtime owns its transport and process teardown; the MCP transport itself @@ -75,6 +107,41 @@ RPC execution metadata uses `codex.app.methods.*`; version-specific schema inspection uses `codex.protocol.methods.*`. Inspecting a schema does not enable that RPC or start a vendor process. +## Reading long threads + +`codex.app.thread.read` returns metadata without turns by default. Use +`codex.app.thread.turns.list` with `thread_id` to read turn metadata (default +20 turns, newest first, without items), then `codex.app.thread.items.list` +to read items (default 50, newest first). Both accept `limit` from 1 to 100, +`cursor` and `sort_direction`. Item pages optionally accept `turn_id`; turn +pages accept the native `items_view` values `notLoaded`, `summary` or `full`. + +For example, read one turn's items with +`{"thread_id":"","turn_id":"","limit":10}`. The result's +`data` holds the page and `nextCursor` is the native continuation. Pass that +cursor unchanged on the next request with the same thread, turn filter and +sort direction. A cursor is not authorization: every request still checks +thread ownership. Cursors follow the installed vendor's history semantics; +they do not freeze an actively changing thread. + +Successful pages retain their complete fields and continuation. If a page +exceeds the adapter output budget, `codex.app.history_page_too_large` reports +an error instead of returning a truncated page. Keep the input cursor and +retry with a smaller limit. For turns, use `items_view: "notLoaded"` and load +items separately. A single oversized item can still exceed the budget; +`codex.app.thread.recent` offers a bounded persisted summary with visible +read/output limits and a snapshot-bound `next_before_cursor`. + +Explicit `include_turns: true` on `thread.read` retains full-history behavior, +including its timeout, transport and output limits. Inspection does not require +`thread.reclaim`: reclaim acquires a writer and remains a separate operation. +`thread.reclaim` and `thread.fork` also omit turns from their response by +default, using native `excludeTurns: true`; their `include_turns: true` option +requests full history. Writer conflicts retain their native ownership meaning +and are not repaired by reconnecting or force-stopping another client. +Native tools also expose `thread/read`, `thread/turns/list` and +`thread/items/list` with the SDK's complete schemas and parameter names. + ## Workspace and diagnostic availability Managed-worktree plans are bound to the source workspace and a live parent @@ -117,6 +184,21 @@ dropped stdout/stderr bytes. Capture-budget failure is explicit; preserved outpu must not be treated as complete. Adapter text truncation and missed event cursors are separate, visible limits. +Completed Exec results remain readable until explicit `codex.exec.release` or +capacity eviction. Release requires both a settled result and confirmed native +cleanup; it rejects running, still-settling and uncertain-cleanup sessions. +Releasing removes the adapter session, its result and buffered events. It does +not delete native conversation history or its workspace ownership record. +Result and event reads are non-destructive. Capacity eviction removes only +settled results with confirmed cleanup; uncertain work continues to occupy a slot. + +Exec shutdown also covers native requests still starting. It refuses new +start/resume calls before claiming thread ownership and waits for admitted +startup and owned process cleanup outcomes. +If a cancelled startup supplies a late process handle, the adapter cleans that +handle before returning. Cancellation of a start request after it has returned +does not replace the explicit session cancel operation. + ## Subject-bound adapter storage Host-launched adapter records are kept under `subjects//codex.sqlite` diff --git a/Package.resolved b/Package.resolved index 4810107..1a64764 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a4250340ac28abf1ac417498a0595591041765d2544ab790b1baf28e94594952", + "originHash" : "2710938c984b3e8c81af989ff73d285a9a9a153f5edc7b8ca5c050bdc0459f8a", "pins" : [ { "identity" : "async-http-client", @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-library/swift-codex.git", "state" : { - "revision" : "63913b732da36233591e68db3772483c35fbae07", - "version" : "0.2.2" + "revision" : "35e4a3dc07e60f084efb8a06424a8315c2ab6f64", + "version" : "0.4.1" } }, { @@ -231,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "a931f2c1de8dd49381ce3bf2e279d033f68d8865", - "version" : "2.102.0" + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" } }, { @@ -283,10 +283,10 @@ { "identity" : "swift-sdk", "kind" : "remoteSourceControl", - "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", + "location" : "https://github.com/computer-mcp/swift-sdk.git", "state" : { - "revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605", - "version" : "0.12.1" + "revision" : "61914e07e36440e0a4ee08d9f4644f20e4fa41f4", + "version" : "0.13.1-computer-mcp.1" } }, { diff --git a/Package.swift b/Package.swift index 5ba87db..b0cf75b 100644 --- a/Package.swift +++ b/Package.swift @@ -7,10 +7,12 @@ let package = Package( platforms: [.macOS(.v14)], products: [.executable(name: "codex-mcp-adapter", targets: ["CodexMCPAdapter"])], dependencies: [ + .package(url: "https://github.com/apple/swift-crypto.git", exact: "4.5.2"), + .package(url: "https://github.com/apple/swift-system.git", exact: "1.8.1"), .package(url: "https://github.com/groue/GRDB.swift.git", exact: "7.11.1"), .package(url: "https://github.com/apple/swift-argument-parser", exact: "1.8.2"), - .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", exact: "0.12.1"), - .package(url: "https://github.com/swift-library/swift-codex.git", exact: "0.2.2"), + .package(url: "https://github.com/computer-mcp/swift-sdk.git", exact: "0.13.1-computer-mcp.1"), + .package(url: "https://github.com/swift-library/swift-codex.git", exact: "0.4.1"), .package(url: "https://github.com/swiftlang/swift-subprocess.git", exact: "0.4.0"), ], targets: [ @@ -18,10 +20,16 @@ let package = Package( name: "CodexAdapter", dependencies: [ .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "Crypto", package: "swift-crypto", condition: .when(platforms: [.windows])), .product(name: "MCP", package: "swift-sdk"), + .product( + name: "SystemPackage", package: "swift-system", condition: .when(platforms: [.windows])), .product(name: "CodexAppServerClient", package: "swift-codex"), .product(name: "CodexAppServerProtocol", package: "swift-codex"), .product(name: "CodexAppServerRuntime", package: "swift-codex"), + .product( + name: "CodexAppServerStdio", package: "swift-codex", + condition: .when(platforms: [.windows])), .product(name: "CodexExec", package: "swift-codex"), .product(name: "Subprocess", package: "swift-subprocess"), ], diff --git a/README.md b/README.md index f03fb37..b15418e 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ The plugin does not link Computer MCP Core or install the vendor Codex binary. ## Current capabilities -The configured server exposes six `codex.exec.*` tools for sessions, bounded -events, results and cancellation, alongside the App Server tools below. +The configured server exposes seven `codex.exec.*` tools for sessions, bounded +events, results, cancellation and retained-result release, alongside the App Server tools below. `codex.protocol.methods.list` and `codex.protocol.methods.describe` inspect bundled, version-specific protocol declarations. Schema presence does not prove @@ -18,13 +18,32 @@ App Server exposes thread/turn and Goal operations, approvals, user input, events, runtime ownership and release, recent-thread inspection, acceptance runs, worktree leases and operational diagnostics. Managed-worktree planning and receipts use the adapter's database; provisioning and removal require a -connected host workspace service. `codex.app.methods.list` and `describe` describe its -callable RPC surface; `codex.app.methods.call` uses the same runtime validation. +connected host workspace service. + +Every SDK-adopted stable request also has a `codex.app.native.*` tool with its +complete native parameter schema. For example, `codex.app.native.fs.readFile` +takes `{"params":{"path":"/absolute/path"}}`. `codex.app.methods.list` and +`describe` report stability and operation risk; `codex.app.methods.call` shares +the native runtime validation and accepts experimental methods with +`experimental: true` when the runtime enables experimental API support. +Native tools preserve request extensions, response fields and exact signed +64-bit integers. Powerful operations still require the host's corresponding +authorization. Higher-level thread, approval and worktree workflows remain +available alongside the native tools. + +Thread reads return metadata by default. Use `codex.app.thread.turns.list` +and `codex.app.thread.items.list` for bounded history pages with native cursors. +Explicit full-history reads remain available through `include_turns: true`; +large histories can exceed transport, output or timeout limits. See the +[long-thread workflow](Documentation/Reference/Workflows.md#reading-long-threads). ## Build and run -Building requires macOS 14 or newer and Swift 6.2 or newer. -Run `swift build` and `swift test`. Launch +On macOS, building requires macOS 14 or newer and Swift 6.2 or newer. +Run `swift build` and `swift test`. Windows x86_64 packaging uses Swift 6.2.3, +PowerShell and the pinned SQLite build described in +[Installation](Documentation/Reference/Installation.md). The Windows adapter +serves standard MCP over stdio; it does not provide a Windows host GUI. Launch `.build/debug/codex-mcp-adapter` through an MCP stdio client, never as an unbounded unattended shell command. `--help` prints usage without serving. @@ -75,12 +94,21 @@ The [documentation index](Documentation/README.md) and ## Protocol inputs -The bundled inventory was exported by Codex 0.154.0. Reproduce it using that -version's `app-server generate-json-schema --out EXPORT_ROOT/stable` and -`app-server generate-json-schema --experimental --out EXPORT_ROOT/experimental`. -Run `node Scripts/import-schema.mjs EXPORT_ROOT 0.154.0`, or add `--check` -to verify byte-for-byte drift. Do not edit generated JSON by hand. -Schema receipt integrity is not publisher signature verification. +The bundled inventory and adoption metadata derive from the exact swift-codex +commit in `Package.resolved`. swift-codex owns the upstream schema lock, +generation and adoption decisions. Regenerate the downstream resources after +resolving dependencies: + +```sh +node Scripts/import-schema.mjs +node Scripts/import-schema.mjs --check +node --test Tests/schema-import.test.mjs +``` + +An optional SDK repository path supplies Git objects for the locked commit; +uncommitted files in that repository are never imported. The receipt binds the +SDK revision, upstream identity and derived resource digests. Do not edit the +resources by hand. Resource integrity is not publisher signature verification. `codex-mcp-adapter compare-schema BASELINE_JSON_DIRECTORY CURRENT_JSON_DIRECTORY` reports all four message directions, source digests, method and schema changes, diff --git a/Scripts/build-windows-sqlite.ps1 b/Scripts/build-windows-sqlite.ps1 new file mode 100644 index 0000000..5546b6a --- /dev/null +++ b/Scripts/build-windows-sqlite.ps1 @@ -0,0 +1,57 @@ +param([Parameter(Mandatory = $true)][string]$OutputDirectory, [switch]$AsJSON) + +$ErrorActionPreference = 'Stop' +if (-not $IsWindows) { throw 'SQLite Windows input requires a native Windows toolchain' } +if (Test-Path $OutputDirectory) { throw 'SQLite build requires a fresh output directory' } +$root = (New-Item -ItemType Directory -Path $OutputDirectory).FullName +$metadataPath = Join-Path $PSScriptRoot 'windows-sqlite.json' +$metadata = Get-Content $metadataPath -Raw | ConvertFrom-Json +Copy-Item $metadataPath (Join-Path $root 'source.json') +$archive = Join-Path $root 'source.zip' +Invoke-WebRequest -Uri $metadata.url -OutFile $archive -TimeoutSec 120 -MaximumRetryCount 2 -RetryIntervalSec 2 +if ((Get-Item $archive).Length -ne $metadata.size -or + (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $metadata.sha256) { + throw 'SQLite source archive does not match the pinned size and checksum' +} +Expand-Archive -Path $archive -DestinationPath $root +$include = Join-Path $root $metadata.directory +$compiler = (Get-Command clang-cl -ErrorAction Stop).Source +$librarian = (Get-Command llvm-lib -ErrorAction Stop).Source +& $compiler --version *> (Join-Path $root 'compiler.txt') +if ($LASTEXITCODE -ne 0) { throw 'SQLite compiler unavailable' } +$object = Join-Path $root 'sqlite3.obj' +$library = Join-Path $root 'sqlite3.lib' +$defines = @($metadata.compileDefinitions | ForEach-Object { "/D$_" }) +$compileArguments = @('/nologo', '/c', '/O2', '/MD') + $defines + @((Join-Path $include 'sqlite3.c'), "/Fo$object") +& $compiler @compileArguments *> (Join-Path $root 'compile.log') +if ($LASTEXITCODE -ne 0) { throw 'SQLite static compilation failed; see compile.log' } +& $librarian /nologo "/out:$library" $object *> (Join-Path $root 'library.log') +if ($LASTEXITCODE -ne 0) { throw 'SQLite static archive failed; see library.log' } + +$probe = Join-Path $PSScriptRoot '../Tests/WindowsSQLite/verify.c' +$executable = Join-Path $root 'verify.exe' +$probeObject = Join-Path $root 'verify.obj' +$probeArguments = @('/nologo', '/c', '/O2', '/MD', "/I$include", $probe, "/Fo$probeObject") +& $compiler @probeArguments *> (Join-Path $root 'verify-compile.log') +if ($LASTEXITCODE -ne 0) { throw 'SQLite verification compilation failed; see verify-compile.log' } +$linkArguments = @('/nologo', '/MD', $probeObject, $library, "/Fe$executable") +& $compiler @linkArguments *> (Join-Path $root 'verify-link.log') +if ($LASTEXITCODE -ne 0) { throw 'SQLite verification linking failed; see verify-link.log' } +& $executable (Join-Path $root 'verify.sqlite') $metadata.version *> (Join-Path $root 'verify.json') +if ($LASTEXITCODE -ne 0) { throw 'SQLite native verification failed; see verify.json' } + +$receipt = [pscustomobject]@{ + source = $metadata + compiler = $compiler + librarian = $librarian + compileArguments = $compileArguments + verificationArguments = $probeArguments + verificationLinkArguments = $linkArguments + librarySHA256 = (Get-FileHash $library -Algorithm SHA256).Hash.ToLowerInvariant() + headerSHA256 = (Get-FileHash (Join-Path $include 'sqlite3.h') -Algorithm SHA256).Hash.ToLowerInvariant() + probeSHA256 = (Get-FileHash $probe -Algorithm SHA256).Hash.ToLowerInvariant() + verification = (Get-Content (Join-Path $root 'verify.json') -Raw | ConvertFrom-Json) +} +$receipt | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $root 'receipt.json') +$result = [pscustomobject]@{ includeDirectory = $include; libraryDirectory = $root; receipt = $receipt } +if ($AsJSON) { $result | ConvertTo-Json -Depth 10 } else { $result } diff --git a/Scripts/check-package.py b/Scripts/check-package.py new file mode 100644 index 0000000..ab373bc --- /dev/null +++ b/Scripts/check-package.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Verify exact archive, inventory and manifest bytes; optionally relocate to a new directory.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import stat +import tempfile +import tomllib +import zipfile + +from package import input_kind, publish_directory, validate_architectures + + +def digest(stream): + result = hashlib.sha256() + while chunk := stream.read(1024 * 1024): + result.update(chunk) + return result.hexdigest() + + +def safe_path(name): + if not isinstance(name, str) or not name or "\\" in name or ":" in name or "\0" in name: + raise ValueError("Archive path must be a canonical relative POSIX path") + parts = name.split("/") + if (any(part in {"", ".", ".."} or part.endswith((".", " ")) for part in parts) + or any(re.fullmatch(r"(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?", part) for part in parts)): + raise ValueError("Archive path is unsafe or platform-ambiguous") + return PurePosixPath(name) + + +def verify(archive, receipt_path, manifest, destination=None): + for path in [archive, receipt_path, manifest]: + if not stat.S_ISREG(input_kind(path)): + raise ValueError("Archive verification inputs must be regular files") + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + inventory = receipt.get("files") + if not isinstance(inventory, dict) or not inventory: + raise ValueError("Archive receipt must declare its file inventory") + for name, value in inventory.items(): + safe_path(name) + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value): + raise ValueError("Archive inventory requires exact SHA-256 digests") + expected_name = validate_architectures(manifest, receipt["architectures"], receipt["platform"]) + declaration = tomllib.loads(manifest.read_text(encoding="utf-8")) + if receipt.get("plugin_id") != declaration["id"] or archive.name != expected_name or receipt["archive"] != archive.name: + raise ValueError("Archive identity does not match its platform declaration") + with archive.open("rb") as stream: + archive_digest = digest(stream) + if archive.stat().st_size != receipt["archive_bytes"] or archive_digest != receipt["archive_sha256"]: + raise ValueError("Archive differs from its accepted receipt") + stage = None + with zipfile.ZipFile(archive) as zipped: + files = {} + seen = set() + directories = set() + for entry in zipped.infolist(): + name = entry.filename[:-1] if entry.is_dir() else entry.filename + safe_path(name) + if entry.orig_filename != entry.filename or name.casefold() in seen or entry.flag_bits & 1: + raise ValueError("Archive contains duplicate, aliased or encrypted entries") + seen.add(name.casefold()) + mode = entry.external_attr >> 16 + expected_mode = stat.S_IFDIR if entry.is_dir() else stat.S_IFREG + if stat.S_IFMT(mode) not in {0, expected_mode} or mode & 0o7000: + raise ValueError("Archive contains links, special files or privileged modes") + if entry.is_dir(): + if entry.file_size != 0: + raise ValueError("Archive directory has unexpected data") + directories.add(name) + else: + files[name] = entry + if set(files) != set(inventory): + raise ValueError("Archive file inventory differs from the accepted receipt") + parents = set() + canonical = {} + for name in files: + for path in [PurePosixPath(name), *PurePosixPath(name).parents]: + if path == PurePosixPath("."): + continue + value = path.as_posix() + previous = canonical.setdefault(value.casefold(), value) + if previous != value: + raise ValueError("Archive contains case-aliased parent paths") + parents.update(parent.as_posix() for parent in PurePosixPath(name).parents if parent != PurePosixPath(".")) + if not directories <= parents or parents & set(files): + raise ValueError("Archive contains undeclared directories or file/directory collisions") + for name, entry in files.items(): + with zipped.open(entry) as stream: + if digest(stream) != inventory[name]: + raise ValueError(f"Archive file digest differs: {name}") + if zipped.read("computer-mcp-plugin.toml") != manifest.read_bytes(): + raise ValueError("Archive manifest must match the repository declaration byte for byte") + try: + if destination is not None: + if os.path.lexists(destination): + raise ValueError("Relocation destination already exists") + destination.parent.mkdir(parents=True, exist_ok=True) + stage = Path(tempfile.mkdtemp(prefix="codex-relocation-", dir=destination.parent)) + for name, entry in files.items(): + output = stage.joinpath(*PurePosixPath(name).parts) + output.parent.mkdir(parents=True, exist_ok=True) + with zipped.open(entry) as source, output.open("xb") as target: + shutil.copyfileobj(source, target, length=1024 * 1024) + if os.name != "nt": + output.chmod((entry.external_attr >> 16) & 0o777 or 0o644) + publish_directory(stage, destination) + stage = None + finally: + if stage is not None: + shutil.rmtree(stage) + return {"archive": archive.name, "archive_sha256": archive_digest, + "platform": receipt["platform"], "architectures": receipt["architectures"], + "plugin_id": declaration["id"], "version": declaration["version"], + "files_verified": len(files), "manifest_byte_identical": True, + "relocated_to": str(destination) if destination is not None else None} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive", type=Path, required=True) + parser.add_argument("--receipt", type=Path, required=True) + parser.add_argument("--manifest", type=Path, default=Path(__file__).resolve().parents[1] / "computer-mcp-plugin.toml") + parser.add_argument("--destination", type=Path) + args = parser.parse_args() + print(json.dumps(verify(args.archive, args.receipt, args.manifest, args.destination), indent=2)) diff --git a/Scripts/check-windows-artifact.ps1 b/Scripts/check-windows-artifact.ps1 new file mode 100644 index 0000000..624192c --- /dev/null +++ b/Scripts/check-windows-artifact.ps1 @@ -0,0 +1,140 @@ +param( + [Parameter(Mandatory = $true)][string]$SourceRun, + [Parameter(Mandatory = $true)][string]$SDKRevision +) +$ErrorActionPreference = 'Stop' +if ($SourceRun -cnotmatch '^[0-9]+$' -or $SDKRevision -cnotmatch '^[0-9a-f]{40}$') { + throw 'Native artifact acceptance requires a run ID and exact SDK revision' +} +$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +Set-Location $repository +$root = Join-Path $repository '.build/windows-artifact' +if (Test-Path $root) { throw 'Artifact acceptance requires a new output directory' } +$evidence = Join-Path $root 'evidence' +New-Item -ItemType Directory -Path $evidence -Force | Out-Null +$runJSON = gh api "repos/$env:GITHUB_REPOSITORY/actions/runs/$SourceRun" +if ($LASTEXITCODE -ne 0) { throw 'Cannot inspect source run' } +$run = $runJSON | ConvertFrom-Json +if ($run.status -ne 'completed' -or $run.path -ne '.github/workflows/validate.yml' -or + $run.head_repository.full_name -ne $env:GITHUB_REPOSITORY -or $run.head_sha -cnotmatch '^[0-9a-f]{40}$') { + throw 'Source must be a completed validation run from this repository' +} +$runJSON | Set-Content (Join-Path $evidence 'source-run.json') +$name = "codex-plugin-windows-source-$SourceRun-$($run.run_attempt)" +$download = Join-Path $root 'source-artifact' +gh run download $SourceRun --repo $env:GITHUB_REPOSITORY --name $name --dir $download +if ($LASTEXITCODE -ne 0) { throw 'Cannot download selected source artifact' } +$inputs = Get-Content (Join-Path $download 'inputs.json') -Raw | ConvertFrom-Json +$link = Get-Content (Join-Path $download 'linked-product-results.json') -Raw | ConvertFrom-Json +if ($inputs.adapterRevision -ne $run.head_sha -or $inputs.sdkRevision -ne $SDKRevision -or + !$inputs.completeAdapter -or $link.linkExitCode -ne 0) { + throw 'Artifact does not bind the requested complete linked adapter and SDK' +} +$product = (Resolve-Path (Join-Path $download 'linked-product')).Path +$files = @(Get-Content (Join-Path $download 'linked-product-files.json') -Raw | ConvertFrom-Json) +$seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) +foreach ($file in $files) { + if ([IO.Path]::IsPathRooted($file.path) -or $file.path.Contains(':') -or + @($file.path -split '[/\\]' | Where-Object { $_ -in @('', '.', '..') }).Count -gt 0 -or + !$seen.Add($file.path)) { throw 'Invalid artifact inventory path' } + $path = Join-Path $product $file.path + $item = Get-Item $path + if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -ne $file.bytes -or + (Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant() -ne $file.sha256) { + throw 'Linked payload differs from its source inventory' + } +} +if (@(Get-ChildItem $product -Recurse -File).Count -ne $files.Count -or + @(Get-ChildItem $product -Recurse | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 }).Count -gt 0) { + throw 'Linked payload contains undeclared or linked files' +} +Copy-Item (Join-Path $download 'inputs.json') $evidence +Copy-Item (Join-Path $download 'linked-product-files.json') $evidence +swift --version | Set-Content (Join-Path $evidence 'toolchain.txt') +if ($LASTEXITCODE -ne 0) { throw 'Swift runtime environment unavailable' } +$runtimeDirectories = @($env:PATH -split ';' | Where-Object { + $_ -and (Test-Path (Join-Path $_ 'swiftCore.dll')) +} | Select-Object -Unique) +if ($runtimeDirectories.Count -eq 0) { throw 'Cannot locate selected Swift runtime' } +$auditArguments = @('--executable', (Join-Path $product 'codex-mcp-adapter.exe'), + '--system-directory', (Join-Path $env:SystemRoot 'System32'), + '--output', (Join-Path $evidence 'runtime-dependencies.json')) +foreach ($directory in $runtimeDirectories) { $auditArguments += @('--runtime-directory', $directory) } +python (Join-Path $PSScriptRoot 'windows_runtime.py') @auditArguments +if ($LASTEXITCODE -ne 0) { throw 'Recursive native runtime dependency audit failed' } +$swift = (Get-Command swift).Source +$swiftInstallation = Split-Path (Split-Path (Split-Path (Split-Path (Split-Path $swift)))) +$notices = @(Get-ChildItem $swiftInstallation -Recurse -File | Where-Object { + $_.Name -match '(LICENSE|NOTICE|COPYING|COPYRIGHT|ThirdParty)' -and $_.Length -lt 1048576 +} | ForEach-Object { + [pscustomobject]@{ path = $_.FullName; bytes = $_.Length + sha256 = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() } +}) +ConvertTo-Json -InputObject $notices -Depth 4 | Set-Content (Join-Path $evidence 'runtime-notice-inventory.json') +$noticeOutput = Join-Path $evidence 'runtime-notices' +foreach ($notice in $notices) { + $destination = Join-Path $noticeOutput ([IO.Path]::GetRelativePath($swiftInstallation, $notice.path)) + New-Item -ItemType Directory -Path (Split-Path $destination) -Force | Out-Null + Copy-Item $notice.path $destination +} +$definitionRevision = git rev-parse HEAD +if ($LASTEXITCODE -ne 0) { throw 'Cannot identify check definition' } +[pscustomobject]@{ + sourceRun = $SourceRun + sourceArtifact = $name + adapterRevision = $inputs.adapterRevision + sdkRevision = $SDKRevision + checkRevision = $definitionRevision + linkedFilesVerified = $files.Count + sourceRebuilt = $false + cleanMachineRelocation = $false + authenticatedModel = $false +} | ConvertTo-Json | Set-Content (Join-Path $evidence 'acceptance-inputs.json') +$fixture = Join-Path $repository 'Tests/WindowsAdapter' +$metadata = Get-Content (Join-Path $fixture 'codex-binary.json') -Raw | ConvertFrom-Json +Copy-Item (Join-Path $fixture 'codex-binary.json') $evidence +$codex = Join-Path $root 'codex' +New-Item -ItemType Directory -Path $codex | Out-Null +$archive = Join-Path $codex 'codex.zip' +Invoke-WebRequest -Uri $metadata.archiveURL -OutFile $archive -TimeoutSec 120 -MaximumRetryCount 2 +if ((Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $metadata.archiveSHA256) { + throw 'Native Codex archive checksum mismatch' +} +Expand-Archive -Path $archive -DestinationPath $codex +$codexBinary = Join-Path $codex $metadata.executable +if ((Get-FileHash $codexBinary -Algorithm SHA256).Hash.ToLowerInvariant() -ne $metadata.executableSHA256) { + throw 'Native Codex executable checksum mismatch' +} +python (Join-Path $fixture 'ProtocolCheck.py') --adapter (Join-Path $product 'codex-mcp-adapter.exe') ` + --codex $codexBinary --evidence-directory (Join-Path $evidence 'protocol') ` + *> (Join-Path $evidence 'protocol.log') +$code = $LASTEXITCODE +Get-Content (Join-Path $evidence 'protocol.log') -Tail 80 +if ($code -ne 0) { exit $code } + +# The relocated candidate contains only inventoried product files and selected runtime DLLs. +$relocated = Join-Path $root 'relocated' +Copy-Item $product $relocated -Recurse +$closure = Get-Content (Join-Path $evidence 'runtime-dependencies.json') -Raw | ConvertFrom-Json +if (@($closure.libraries | Where-Object { $_.role -eq 'external-msvc-runtime' }).Count -ne 0) { + throw 'App-local acceptance requires redistributable runtime inputs outside System32' +} +foreach ($library in @($closure.libraries | Where-Object { $_.role -eq 'runtime' })) { + $destination = Join-Path $relocated ([IO.Path]::GetFileName($library.path)) + if (Test-Path $destination) { throw 'Runtime DLL collides with the product payload' } + Copy-Item $library.path $destination + if ((Get-FileHash $destination -Algorithm SHA256).Hash.ToLowerInvariant() -ne $library.sha256) { + throw 'Relocated runtime DLL differs from its inspected source' + } +} +@(Get-ChildItem $relocated -Recurse -File | ForEach-Object { + [pscustomobject]@{ path = [IO.Path]::GetRelativePath($relocated, $_.FullName); bytes = $_.Length + sha256 = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() } +}) | ConvertTo-Json -Depth 4 | Set-Content (Join-Path $evidence 'relocated-files.json') +python (Join-Path $fixture 'ProtocolCheck.py') --adapter (Join-Path $relocated 'codex-mcp-adapter.exe') ` + --codex $codexBinary --evidence-directory (Join-Path $evidence 'app-local-protocol') --app-local-runtime ` + *> (Join-Path $evidence 'app-local-protocol.log') +$relocatedCode = $LASTEXITCODE +Get-Content (Join-Path $evidence 'app-local-protocol.log') -Tail 80 +exit $relocatedCode diff --git a/Scripts/check-windows-package.ps1 b/Scripts/check-windows-package.ps1 new file mode 100644 index 0000000..41c62eb --- /dev/null +++ b/Scripts/check-windows-package.ps1 @@ -0,0 +1,59 @@ +param( + [Parameter(Mandatory = $true)][string]$ArtifactDirectory, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) +$ErrorActionPreference = 'Stop' +$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$artifact = (Resolve-Path $ArtifactDirectory).Path +$root = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path $root) { throw 'Package acceptance requires a new output directory' } +$evidence = Join-Path $root 'evidence' +New-Item -ItemType Directory -Path $evidence | Out-Null +$receiptPath = Join-Path $artifact 'receipt.json' +$receipt = Get-Content $receiptPath -Raw | ConvertFrom-Json +if ($receipt.platform -ne 'windows' -or $receipt.configuration -ne 'release' -or + $receipt.archive -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*\.zip$') { + throw 'Expected a native Windows release package receipt' +} +$revision = git -C $repository rev-parse HEAD +if ($LASTEXITCODE -ne 0) { throw 'Cannot identify acceptance source' } +[pscustomobject]@{ + sourceRevision = $revision + runnerImage = $env:ImageOS + runnerImageVersion = $env:ImageVersion + runnerOS = $env:RUNNER_OS + runnerArchitecture = $env:RUNNER_ARCH + existingSwiftCommands = @((Get-Command swift -All -ErrorAction SilentlyContinue).Source) + swiftToolchainInstalledByThisJob = $false + pristineWindowsImage = $false + sourceRebuilt = $false + authenticatedModel = $false +} | ConvertTo-Json -Depth 4 | Set-Content (Join-Path $evidence 'inputs.json') +Copy-Item $receiptPath (Join-Path $evidence 'package-receipt.json') +$relocated = Join-Path $root 'relocated package 汉字' +python (Join-Path $PSScriptRoot 'check-package.py') --archive (Join-Path $artifact $receipt.archive) ` + --receipt $receiptPath --destination $relocated *> (Join-Path $evidence 'archive-check.json') +if ($LASTEXITCODE -ne 0) { throw 'Archive inventory, manifest or relocation verification failed' } + +# This CLI is a private validation input and never becomes part of the plugin archive. +$fixture = Join-Path $repository 'Tests/WindowsAdapter' +$metadata = Get-Content (Join-Path $fixture 'codex-binary.json') -Raw | ConvertFrom-Json +Copy-Item (Join-Path $fixture 'codex-binary.json') $evidence +$codex = Join-Path $root 'codex' +New-Item -ItemType Directory -Path $codex | Out-Null +$archive = Join-Path $codex 'codex.zip' +Invoke-WebRequest -Uri $metadata.archiveURL -OutFile $archive -TimeoutSec 120 -MaximumRetryCount 2 +if ((Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $metadata.archiveSHA256) { + throw 'Native Codex archive checksum mismatch' +} +Expand-Archive -Path $archive -DestinationPath $codex +$codexBinary = Join-Path $codex $metadata.executable +if ((Get-FileHash $codexBinary -Algorithm SHA256).Hash.ToLowerInvariant() -ne $metadata.executableSHA256) { + throw 'Native Codex executable checksum mismatch' +} +python (Join-Path $fixture 'ProtocolCheck.py') --adapter (Join-Path $relocated 'bin/codex-mcp-adapter.exe') ` + --codex $codexBinary --evidence-directory (Join-Path $evidence 'protocol') --app-local-runtime ` + *> (Join-Path $evidence 'protocol.log') +$code = $LASTEXITCODE +Get-Content (Join-Path $evidence 'protocol.log') -Tail 80 +exit $code diff --git a/Scripts/check-workflow.py b/Scripts/check-workflow.py index d585a6d..6eb9378 100644 --- a/Scripts/check-workflow.py +++ b/Scripts/check-workflow.py @@ -89,6 +89,8 @@ def __init__(self, process, tool_prefix=""): self.tool_prefix = tool_prefix self.messages = queue.Queue(maxsize=1024) self.sequence = 0 + self.last_invocation = None + self.work_snapshot = None self.reader = threading.Thread(target=self.read, daemon=True) self.reader.start() @@ -108,6 +110,9 @@ def send(self, message): self.process.stdin.flush() def request(self, method, params): + if method == "tools/call" and not self.tool_prefix: + self.last_invocation = str(uuid.uuid4()) + params = dict(params, _meta={"io.github.computer-mcp/work-invocation": self.last_invocation}) self.sequence += 1 request_id = self.sequence self.send({"id": request_id, "method": method, "params": params}) @@ -123,12 +128,47 @@ def request(self, method, params): return message["result"] raise TimeoutError(method) + def work(self): + assert not self.tool_prefix, "The Gateway consumes provider work privately" + uri = "computer-mcp://runtime/work/v1" + result = self.request("resources/read", {"uri": uri}) + assert len(result["contents"]) == 1, result + content = result["contents"][0] + assert content["uri"] == uri and content["mimeType"] == "application/json", content + assert len(content["text"].encode()) <= 524288 + value = json.loads(content["text"]) + assert value["format_version"] == 1 and len(value["resources"]) <= 1024, value + assert str(uuid.UUID(value["instance_id"])) == value["instance_id"], value + if self.work_snapshot: + assert value["instance_id"] == self.work_snapshot["instance_id"] + assert value["revision"] >= self.work_snapshot["revision"] + if value["revision"] == self.work_snapshot["revision"]: + assert value == self.work_snapshot, "Unchanged revision changed work" + self.work_snapshot = value + return value["resources"] + def call(self, suffix, **arguments): result = self.request("tools/call", {"name": self.tool_prefix + "codex.app." + suffix, "arguments": arguments}) if result.get("isError"): raise RuntimeError(f"{suffix}: {result['content']}") return result["structuredContent"]["result"] + def host_work(self, count): + deadline = time.monotonic() + 15 + observed = None + while time.monotonic() < deadline: + result = self.request("tools/call", {"name": "mcp.servers.status", "arguments": {}}) + assert not result.get("isError"), result + servers = result["structuredContent"]["result"]["servers"] + assert len(servers) == 1, servers + observed = servers[0]["connection"].get("provider_work") + assert isinstance(observed, dict), "Candidate host lacks provider-work observation" + if (observed["resource_count"] == count and observed["unsettled_invocation_count"] == 0 + and not observed["observation_pending"]): + return observed + time.sleep(.03) + raise AssertionError(f"Candidate host did not observe {count} settled resources: {observed}") + def verify_owned_stop(status): assert status["runtime_state"] == "stopped", status snapshot = status["process"] @@ -144,7 +184,8 @@ def verify_owned_stop(status): return snapshot -def run(adapter, codex, gateway=None, database=None, control_socket=None, registered_workspace=None): +def run(adapter, codex, gateway=None, database=None, control_socket=None, registered_workspace=None, + require_host_work=False): # A vendor launcher may use an interpreter installed beside it (for example Node). runtime_path = str(codex.parent) + os.pathsep + "/usr/bin:/bin" codex_version = subprocess.run([str(codex), "--version"], env={"PATH": runtime_path}, @@ -206,7 +247,11 @@ def run(adapter, codex, gateway=None, database=None, control_socket=None, regist tool_risks.update({"codex." + name: "workspace-write" for name in write_tools}) risk_table = ", ".join(json.dumps(name) + " = " + json.dumps(risk) for name, risk in tool_risks.items()) manifest.write_text('schema_version = 1\n[runtime]\ncaller = "local-cli"\nprofile = "local-admin"\n' - '[policy]\nshell_enabled = false\n[[mcp.servers]]\nid = "adapter"\ntransport = "stdio"\n' + '[policy]\nshell_enabled = false\n' + '[[profiles]]\nid = "local-admin"\nmode = "local-full-access"\n' + 'confirmation_policy = "never"\nfull_shell_enabled = true\n' + 'capabilities = ["*"]\nworkspaces = ["*"]\nallowed_callers = ["local-cli"]\n' + '[[mcp.servers]]\nid = "adapter"\ntransport = "stdio"\n' 'command = ' + json.dumps(str(adapter)) + '\nargs = ' + json.dumps(launch_arguments[1:]) + '\n' 'allowed_tools = ' + json.dumps(list(tool_risks)) + '\n' 'tool_risks = { ' + risk_table + ' }\nexposure = "reexport"\nprefix = "adapter"\n' @@ -254,7 +299,16 @@ def run(adapter, codex, gateway=None, database=None, control_socket=None, regist required = {"thread.start", "thread.list", "thread.read", "thread.loaded.list", "thread.fork", "thread.release", "thread.reclaim", "goal.set", "goal.get", "goal.clear", "turn.start", "turn.steer", "events.read", "approvals.list", "approvals.respond", "runtime.stop"} - assert {client.tool_prefix + "codex.app." + name for name in required} <= names + missing = {client.tool_prefix + "codex.app." + name for name in required} - names + assert not missing, {"missing_tools": sorted(missing), "listed_tools": sorted(names)} + if require_host_work: + assert all(key not in tool.get("_meta", {}) for tool in catalog + for key in ("io.github.computer-mcp/work", "io.github.computer-mcp/continuation")), catalog + if not gateway: + for tool in catalog: + assert tool["_meta"]["io.github.computer-mcp/work"] == { + "format_version": 1, "uri": "computer-mcp://runtime/work/v1"} + assert client.work() == [], "Discovery started native work" diagnostic_result = client.request("tools/call", { "name": client.tool_prefix + "codex.diagnostics.snapshot", "arguments": {"limit": 10}}) assert diagnostic_result.get("isError") is False, diagnostic_result @@ -268,10 +322,20 @@ def run(adapter, codex, gateway=None, database=None, control_socket=None, regist receipt["steps"].append("diagnostics→adapter persistence→host data explicitly unavailable") assert client.call("thread.list")["data"] == [] started = client.call("thread.start") + thread_origin = client.last_invocation thread_id = started["thread"]["id"] assert thread_id in client.call("thread.loaded.list")["data"] + if require_host_work: + receipt["host_work_opened"] = client.host_work(1) + if not gateway: + rows = [row for row in client.work() if row["kind"] == "codex.app.thread"] + assert len(rows) == 1 and rows[0]["acquired_by"] == thread_origin, rows + assert rows[0]["handles"]["thread_id"] == thread_id, rows + runtime_id = rows[0]["handles"]["runtime_id"] + assert str(uuid.UUID(runtime_id)).lower() == runtime_id.lower(), rows receipt["steps"].append("thread/list→start→loaded/list") turn = client.call("turn.start", thread_id=thread_id, prompt="Return the fixture response.") + turn_origin = client.last_invocation turn_id = turn["turn"]["id"] cursor = 0 deadline = time.monotonic() + 30 @@ -294,6 +358,14 @@ def run(adapter, codex, gateway=None, database=None, control_socket=None, regist assert approval["kind"] == "command_execution", approval assert Path(approval["workspace_path"]).resolve() == workspace, approval assert approval["thread_id"] == thread_id, approval + if not gateway: + rows = [row for row in client.work() if row["kind"] == "codex.app.server-request"] + assert rows and all(row["acquired_by"] == turn_origin for row in rows), rows + owned = [row for row in rows if row["handles"].get("approval_id") == approval["id"]] + assert len(owned) == 1, rows + assert owned[0]["handles"]["thread_id"] == thread_id, owned + assert owned[0]["handles"]["turn_id"] == turn_id, owned + assert owned[0]["handles"]["runtime_id"] == runtime_id, owned client.call("approvals.respond", approval_id=approval["id"], response={"decision": "accept"}) approved += 1 if completed is None: @@ -329,6 +401,11 @@ def run(adapter, codex, gateway=None, database=None, control_socket=None, regist assert forced["externally_claimable"] is True, forced stopped = client.call("status") receipt["first_owned_stop"] = verify_owned_stop(stopped) + if require_host_work: + receipt["host_work_released"] = client.host_work(0) + if not gateway: + assert client.work() == [], "Confirmed process exit retained native work" + receipt["steps"].append("complete work resource→native creator/callback correlation→confirmed cleanup") process.stdin.close() assert process.wait(timeout=10) == 0 client.reader.join(timeout=2) @@ -353,6 +430,8 @@ def run(adapter, codex, gateway=None, database=None, control_socket=None, regist receipt["steps"].append("active turn→steer→reviewed interrupt/release→owned runtime stop") stopped = client.call("runtime.stop") receipt["final_owned_stop"] = verify_owned_stop(stopped) + if not gateway: + assert client.work() == [], "Final owned cleanup did not settle work" confirmed = True process.stdin.close() assert process.wait(timeout=10) == 0 @@ -391,7 +470,11 @@ def run(adapter, codex, gateway=None, database=None, control_socket=None, regist parser.add_argument("--database", type=Path, help="Fresh isolated host-test database containing an installed Codex plugin") parser.add_argument("--control-socket", type=Path, help="Owner socket of that isolated host-test instance") parser.add_argument("--workspace", type=Path, help="Existing disposable workspace registered in that test database") + parser.add_argument("--require-host-work", action="store_true", + help="Require the candidate Gateway to accept provider aliases and observe final release") options = parser.parse_args() + if options.require_host_work and not options.gateway: + parser.error("Host work observation requires --gateway") fixture_options = [options.database, options.control_socket, options.workspace] if any(fixture_options) and (not all(fixture_options) or not options.gateway): parser.error("Installed-plugin checks require --gateway, --database, --control-socket and --workspace together") @@ -401,4 +484,5 @@ def run(adapter, codex, gateway=None, database=None, control_socket=None, regist for executable in [options.adapter, options.codex] + ([options.gateway] if options.gateway else []): if not executable.is_absolute() or not os.access(executable, os.X_OK): parser.error("Both executables must be existing absolute executable paths") - print(json.dumps(run(options.adapter, options.codex, options.gateway, options.database, options.control_socket, options.workspace), indent=2)) + print(json.dumps(run(options.adapter, options.codex, options.gateway, options.database, + options.control_socket, options.workspace, options.require_host_work), indent=2)) diff --git a/Scripts/import-schema.mjs b/Scripts/import-schema.mjs index 1979017..bac0626 100644 --- a/Scripts/import-schema.mjs +++ b/Scripts/import-schema.mjs @@ -1,48 +1,108 @@ +import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -const args = process.argv.slice(2); -const check = args.includes('--check'); -const positional = args.filter(x => x !== '--check'); -if (positional.length !== 2 || !/^\d+\.\d+\.\d+$/.test(positional[1])) { - throw new Error('Usage: node Scripts/import-schema.mjs EXPORT_ROOT CODEX_VERSION [--check]'); -} -const [source, codexVersion] = positional; -const output = resolve(dirname(fileURLToPath(import.meta.url)), '../Sources/CodexAdapter/Resources/Protocol'); -const receipt = { codexVersion, files: {} }; -const artifacts = new Map(); -for (const channel of ['stable', 'experimental']) { - for (const kind of ['ClientRequest', 'ClientNotification', 'ServerRequest', 'ServerNotification']) { - const name = `${channel}/${kind}.json`; - const bytes = readFileSync(join(source, name)); - if (bytes.length > 2 * 1024 * 1024) throw new Error(`Schema too large: ${name}`); - const document = JSON.parse(bytes); - if (!Array.isArray(document.oneOf) || document.oneOf.length === 0) { - throw new Error(`Missing message variants: ${name}`); +const channels = ['stable', 'experimental']; +const directions = ['ClientRequest', 'ClientNotification', 'ServerRequest', 'ServerNotification']; +const sdkSchemaRoot = 'Vendor/CodexAppServerProtocolSchema'; +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); + +// Read committed dependency inputs; a local SDK checkout may contain unrelated work. +export function readLockedSnapshot(packageRoot, sdkRepository) { + const resolved = JSON.parse(readFileSync(join(packageRoot, 'Package.resolved'))); + const pins = resolved.pins.filter(pin => pin.identity === 'swift-codex'); + if (pins.length !== 1) throw new Error('Expected one locked swift-codex dependency'); + const { state, location } = pins[0]; + if (location !== 'https://github.com/swift-library/swift-codex.git' || + !/^[0-9a-f]{40}$/.test(state?.revision) || !/^\d+\.\d+\.\d+$/.test(state?.version)) { + throw new Error('Invalid locked swift-codex dependency identity'); + } + const read = (name, limit = 2 * 1024 * 1024) => { + let bytes; + try { + bytes = execFileSync('git', ['-C', sdkRepository, 'show', `${state.revision}:${sdkSchemaRoot}/${name}`], + { maxBuffer: limit + 1, stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (cause) { + throw new Error(`Cannot read locked SDK ${state.revision} input ${name}`, { cause }); } - const methods = document.oneOf.map(variant => { - const names = variant.properties?.method?.enum; - if (!Array.isArray(names) || names.length !== 1 || typeof names[0] !== 'string') { - throw new Error(`Ambiguous method: ${name}`); + if (bytes.length > limit) throw new Error(`SDK projection input exceeds its bound: ${name}`); + return bytes; + }; + const lock = JSON.parse(read('upstream.lock.json', 65536)); + const match = /^rust-v(\d+\.\d+\.\d+)$/.exec(lock.upstream?.tag); + if (!match || !/^[0-9a-f]{40}$/.test(lock.upstream?.commit)) { + throw new Error('SDK lock lacks its exact upstream identity'); + } + const adoptionBytes = read('method-adoption.json', 65536); + const adoption = JSON.parse(adoptionBytes); + if (adoption.schema !== 'swift-codex.codex-app-server-method-adoption.v1' || + adoption.upstreamTag !== lock.upstream.tag || !Array.isArray(adoption.excluded)) { + throw new Error('Invalid SDK method adoption metadata'); + } + const receipt = { + codexVersion: match[1], + sdk: { version: state.version, revision: state.revision }, + upstream: { tag: lock.upstream.tag, commit: lock.upstream.commit }, + adoptionSHA256: digest(adoptionBytes), + files: {}, + }; + const artifacts = new Map([['adoption.json', adoptionBytes]]); + for (const channel of channels) { + const adopted = adoption.adopted?.[channel]; + if (!Array.isArray(adopted) || adopted.some(method => typeof method !== 'string') || + new Set(adopted).size !== adopted.length) throw new Error(`Invalid ${channel} SDK adoption`); + for (const kind of directions) { + const name = `${channel}/${kind}.json`; + const bytes = read(`${channel}/json/${kind}.json`); + const document = JSON.parse(bytes); + if (!Array.isArray(document.oneOf) || document.oneOf.length === 0) { + throw new Error(`Missing message variants: ${name}`); + } + const methods = document.oneOf.map(variant => { + const names = variant.properties?.method?.enum; + if (!Array.isArray(names) || names.length !== 1 || typeof names[0] !== 'string') { + throw new Error(`Ambiguous method: ${name}`); + } + return names[0]; + }); + if (new Set(methods).size !== methods.length) throw new Error(`Duplicate method: ${name}`); + if (kind === 'ClientRequest' && adopted.some(method => !methods.includes(method))) { + throw new Error(`SDK adoption references an absent ${channel} request`); } - return names[0]; - }); - if (new Set(methods).size !== methods.length) throw new Error(`Duplicate method: ${name}`); - receipt.files[name] = { sha256: createHash('sha256').update(bytes).digest('hex'), messages: methods.length }; - artifacts.set(name, bytes); + receipt.files[name] = { sha256: digest(bytes), messages: methods.length }; + artifacts.set(name, bytes); + } + } + artifacts.set('receipt.json', Buffer.from(JSON.stringify(receipt, null, 2) + '\n')); + return { receipt, artifacts }; +} + +export function syncArtifacts(snapshot, output, check) { + for (const [name, bytes] of snapshot.artifacts) { + const target = join(output, name); + if (check) { + if (!readFileSync(target).equals(bytes)) throw new Error(`SDK projection drift: ${name}`); + } else { + mkdirSync(dirname(target), { recursive: true }); + const temporary = `${target}.${process.pid}.tmp`; + writeFileSync(temporary, bytes); + renameSync(temporary, target); + } } } -artifacts.set('receipt.json', Buffer.from(JSON.stringify(receipt, null, 2) + '\n')); -// Validate the entire input before replacing any derived artifact. -for (const [name, bytes] of artifacts) { - const target = join(output, name); - if (check) { - if (!readFileSync(target).equals(bytes)) throw new Error(`Schema drift: ${name}`); - } else { - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, bytes); + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const args = process.argv.slice(2); + const check = args.includes('--check'); + const positional = args.filter(value => value !== '--check'); + if (positional.length > 1 || positional.some(value => value.startsWith('--'))) { + throw new Error('Usage: node Scripts/import-schema.mjs [SDK_REPOSITORY] [--check]'); } + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const sdk = resolve(positional[0] ?? join(root, '.build/checkouts/swift-codex')); + const snapshot = readLockedSnapshot(root, sdk); + syncArtifacts(snapshot, join(root, 'Sources/CodexAdapter/Resources/Protocol'), check); + process.stdout.write(JSON.stringify({ checked: check, ...snapshot.receipt }, null, 2) + '\n'); } -process.stdout.write(JSON.stringify({ checked: check, ...receipt }, null, 2) + '\n'); diff --git a/Scripts/package.py b/Scripts/package.py index 3bfdc24..6bb142a 100644 --- a/Scripts/package.py +++ b/Scripts/package.py @@ -7,9 +7,11 @@ import json import os from pathlib import Path +import re import shutil import stat import subprocess +import sys import tempfile import tomllib import zipfile @@ -22,38 +24,97 @@ def command(arguments, cwd): return result.stdout.strip() +def current_platform(): + if sys.platform == "darwin": + return "macos" + if sys.platform == "win32": + return "windows" + raise ValueError("Native packaging requires macOS or Windows") + + +def input_kind(path): + metadata = path.lstat() + if getattr(metadata, "st_file_attributes", 0) & 0x400: + raise ValueError(f"Package input is a reparse point: {path}") + return metadata.st_mode + + def copy_file(source, destination): - if not stat.S_ISREG(source.lstat().st_mode): + if not stat.S_ISREG(input_kind(source)): raise ValueError(f"Package input must be a regular file: {source}") shutil.copy2(source, destination, follow_symlinks=False) def copy_tree(source, destination): - if not stat.S_ISDIR(source.lstat().st_mode): + if not stat.S_ISDIR(input_kind(source)): raise ValueError(f"Package input must be a directory: {source}") - # Preserve links for rejection, never copy the files they point at. + validate_payload(source) shutil.copytree(source, destination, symlinks=True) def validate_payload(root): - for entry in root.rglob("*"): - mode = entry.lstat().st_mode - if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)): - raise ValueError(f"Package contains a link or special file: {entry.relative_to(root)}") - - -def validate_architectures(manifest, architectures): + if not stat.S_ISDIR(input_kind(root)): + raise ValueError("Package root must be a regular directory") + pending = [root] + while pending: + for entry in pending.pop().iterdir(): + mode = input_kind(entry) + if stat.S_ISDIR(mode): + pending.append(entry) + elif not stat.S_ISREG(mode): + raise ValueError(f"Package contains a link or special file: {entry.relative_to(root)}") + + +def validate_architectures(manifest, architectures, platform="macos"): declaration = tomllib.loads(manifest.read_text(encoding="utf-8")) compatibility = declaration.get("compatibility", {}) declared = compatibility.get("architectures") if isinstance(compatibility, dict) else None if (not isinstance(declared, list) or not declared or not all(isinstance(value, str) and value for value in declared) or len(set(declared)) != len(declared) - or set(declared) != set(architectures)): - raise ValueError("Manifest compatibility.architectures must exactly match the built adapter slices") + or not set(architectures).issubset(declared)): + raise ValueError("Manifest compatibility.architectures must cover the built adapter slices") + platforms = compatibility.get("platforms", ["macos"]) + if (not isinstance(platforms, list) or not all(isinstance(value, str) and value for value in platforms) + or platform not in platforms or len(set(platforms)) != len(platforms)): + raise ValueError("Manifest compatibility.platforms must cover the built platform") + artifacts = compatibility.get("artifacts", []) + if not isinstance(artifacts, list) or not all(isinstance(item, dict) for item in artifacts): + raise ValueError("Manifest artifacts must be declarations") + if not artifacts: + if platforms != ["macos"] or set(declared) != set(architectures): + raise ValueError("Manifest architectures must exactly match an explicit artifact target") + return "codex-plugin.zip" + names = set() + matches = [] + for artifact in artifacts: + name = artifact.get("name", "") + if (not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*\.zip", name) + or name.casefold() in names): + raise ValueError("Artifact names must be unique ZIP filenames") + names.add(name.casefold()) + target_platforms = artifact.get("platforms", []) + target_architectures = artifact.get("architectures", []) + if (not isinstance(target_platforms, list) or not isinstance(target_architectures, list) + or not target_platforms or not target_architectures + or not all(isinstance(value, str) and value for value in target_platforms + target_architectures) + or len(set(target_platforms)) != len(target_platforms) + or len(set(target_architectures)) != len(target_architectures) + or not set(target_platforms).issubset(platforms) + or not set(target_architectures).issubset(declared)): + raise ValueError("Artifact target must be contained in package compatibility") + if target_platforms == [platform] and set(target_architectures) == set(architectures): + matches.append(name) + if len(matches) != 1: + raise ValueError("Built platform and architectures must match exactly one named artifact") + return matches[0] def publish_directory(source, destination): + if sys.platform == "win32": + # Windows rename atomically refuses every existing destination. + os.rename(source, destination) + return # Darwin RENAME_EXCL atomically refuses an existing destination, including an empty directory. rename = ctypes.CDLL(None, use_errno=True).renamex_np rename.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] @@ -66,17 +127,31 @@ def publish_directory(source, destination): def package(output, configuration): repo = Path(__file__).resolve().parent.parent check_version(repo) - if output.exists(): + if os.path.lexists(output): raise ValueError("Output already exists; select a new directory to preserve existing artifacts") output.parent.mkdir(parents=True, exist_ok=True) - command(["/usr/bin/swift", "build", "-c", configuration, "--disable-automatic-resolution"], repo) - built = Path(command(["/usr/bin/swift", "build", "-c", configuration, "--show-bin-path"], repo)) - executable = built / "codex-mcp-adapter" - bundle = built / "codex-plugin_CodexAdapter.bundle" - if not executable.is_file() or not bundle.is_dir(): - raise ValueError("The executable and its SwiftPM resource bundle must both be built") stage = Path(tempfile.mkdtemp(prefix="codex-package-", dir=output.parent)) try: + platform = current_platform() + swift = "/usr/bin/swift" if platform == "macos" else shutil.which("swift") + if swift is None: + raise ValueError("Swift must be available to build the native archive") + build_arguments = [] + sqlite = None + if platform == "windows": + shell = shutil.which("pwsh") + if shell is None: + raise ValueError("PowerShell is required for the native SQLite build") + sqlite = json.loads(command([shell, "-NoProfile", "-NonInteractive", "-File", + str(repo / "Scripts/build-windows-sqlite.ps1"), "-OutputDirectory", str(stage / "sqlite"), "-AsJSON"], repo)) + build_arguments = ["-Xcc", "-I" + sqlite["includeDirectory"], + "-Xlinker", "/LIBPATH:" + sqlite["libraryDirectory"]] + command([swift, "build", "-c", configuration, "--disable-automatic-resolution", *build_arguments], repo) + built = Path(command([swift, "build", "-c", configuration, "--show-bin-path"], repo)) + executable = built / ("codex-mcp-adapter.exe" if platform == "windows" else "codex-mcp-adapter") + bundle = built / ("codex-plugin_CodexAdapter.resources" if platform == "windows" else "codex-plugin_CodexAdapter.bundle") + if not executable.is_file() or not bundle.is_dir(): + raise ValueError("The executable and its SwiftPM resource bundle must both be built") payload = stage / "package" binary_directory = payload / "bin" binary_directory.mkdir(parents=True) @@ -105,6 +180,10 @@ def package(output, configuration): for name in ["LICENSE", "NOTICE"]: copy_file(repo / ".build/checkouts/swift-codex/Vendor/CodexAppServerProtocolSchema" / name, schema_notices / name) + runtime_receipt = None + if platform == "windows": + from windows_package import stage_runtime + runtime_receipt = stage_runtime(repo, binary_directory, notices, sqlite, swift, command, copy_file, copy_tree) validate_payload(payload) with (payload / "ThirdPartyNotices.txt").open("wb") as aggregate: for entry in sorted(notices.rglob("*")): @@ -113,11 +192,23 @@ def package(output, configuration): aggregate.write(entry.read_bytes()) aggregate.write(b"\n") binary = binary_directory / executable.name - command(["/usr/bin/codesign", "--force", "--sign", "-", str(binary)], stage) - command(["/usr/bin/codesign", "--verify", "--strict", str(binary)], stage) - architectures = command(["/usr/bin/lipo", "-archs", str(binary)], stage).split() + if platform == "macos": + command(["/usr/bin/codesign", "--force", "--sign", "-", str(binary)], stage) + command(["/usr/bin/codesign", "--verify", "--strict", str(binary)], stage) + architectures = command(["/usr/bin/lipo", "-archs", str(binary)], stage).split() + else: + architectures = [runtime_receipt["architecture"]] manifest = payload / "computer-mcp-plugin.toml" - validate_architectures(manifest, architectures) + archive_name = validate_architectures(manifest, architectures, platform) + declaration = tomllib.loads(manifest.read_text(encoding="utf-8")) + contributions = declaration.get("mcp", []) + if not contributions: + raise ValueError("Manifest must declare its packaged MCP executable") + for contribution in contributions: + selected = contribution.get("executable", {}) + path = selected.get("platform_paths", {}).get(platform, selected.get("path")) + if path != binary.relative_to(payload).as_posix(): + raise ValueError("Manifest executable must select the native packaged adapter") # Basic relocation check; the separate MCP workflow validates resource lookup and execution. command([str(binary), "--help"], stage) declared_version = tomllib.loads(manifest.read_text(encoding="utf-8"))["version"] @@ -130,15 +221,18 @@ def package(output, configuration): if entry.is_symlink(): raise ValueError(f"Unexpected symlink in package: {entry.relative_to(payload)}") if entry.is_file(): - inventory[str(entry.relative_to(payload))] = hashlib.sha256(entry.read_bytes()).hexdigest() - archive = stage / "codex-plugin.zip" + inventory[entry.relative_to(payload).as_posix()] = hashlib.sha256(entry.read_bytes()).hexdigest() + archive = stage / archive_name with zipfile.ZipFile(archive, "x", compression=zipfile.ZIP_DEFLATED) as package_zip: for entry in sorted(payload.rglob("*")): - package_zip.write(entry, str(entry.relative_to(payload))) + package_zip.write(entry, entry.relative_to(payload).as_posix()) receipt = {"plugin_id": "codex", "configuration": configuration, "architectures": architectures, - "signature": "ad-hoc", "publisher_verified": False, "published": False, - "archive": "codex-plugin.zip", "archive_sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "platform": platform, "signature": "ad-hoc" if platform == "macos" else "unsigned", + "publisher_verified": False, "published": False, + "archive": archive_name, "archive_sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), "archive_bytes": archive.stat().st_size, "files": inventory} + if runtime_receipt is not None: + receipt["windows_runtime"] = runtime_receipt (stage / "receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") publish_directory(stage, output) return {"directory": str(output), "archive": str(output / archive.name), diff --git a/Scripts/test-windows-host-mcp.ps1 b/Scripts/test-windows-host-mcp.ps1 new file mode 100644 index 0000000..44649cd --- /dev/null +++ b/Scripts/test-windows-host-mcp.ps1 @@ -0,0 +1,125 @@ +param( + [Parameter(Mandatory = $true)][string]$SDKPath, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) + +$ErrorActionPreference = 'Stop' +$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$sdk = (Resolve-Path $SDKPath).Path +$sdkRevision = git -C $sdk rev-parse HEAD +if ($LASTEXITCODE -ne 0 -or $sdkRevision -cnotmatch '^[0-9a-f]{40}$') { throw 'Cannot identify SDK source' } +git -C $sdk diff --quiet HEAD +if ($LASTEXITCODE -ne 0) { throw 'Host MCP tests require an unchanged committed SDK candidate' } +$adapterRevision = git -C $repository rev-parse HEAD +if ($LASTEXITCODE -ne 0) { throw 'Cannot identify adapter source' } +$shippingPath = Join-Path $repository 'Package.resolved' +$shippingHash = (Get-FileHash $shippingPath -Algorithm SHA256).Hash +$shippingLock = Get-Content $shippingPath -Raw | ConvertFrom-Json +$sdkPin = @($shippingLock.pins | Where-Object { $_.identity -eq 'swift-codex' }) +$mcpPin = @($shippingLock.pins | Where-Object { $_.identity -eq 'swift-sdk' }) +if ($sdkPin.Count -ne 1 -or $sdkPin[0].state.revision -ne $sdkRevision -or + $mcpPin.Count -ne 1 -or $mcpPin[0].location -ne 'https://github.com/computer-mcp/swift-sdk.git' -or + $mcpPin[0].state.version -cnotmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$') { + throw 'Host MCP checks require the shipping dependency identities' +} +if (Test-Path $OutputDirectory) { throw 'Host MCP tests require a fresh output directory' } +New-Item -ItemType Directory -Path $OutputDirectory | Out-Null +$root = (Resolve-Path $OutputDirectory).Path +$evidence = Join-Path $root 'evidence' +$consumer = Join-Path $root 'consumer' +$sources = Join-Path $consumer 'Sources/HostPipe' +$tests = Join-Path $consumer 'Tests/HostPipeTests' +$childSources = Join-Path $consumer 'Sources/HostPipeFixture' +New-Item -ItemType Directory -Path $evidence, $sources, $tests, $childSources | Out-Null +$fixture = Join-Path $repository 'Tests/WindowsHostMCP' +$manifest = (Get-Content (Join-Path $fixture 'Package.swift.template') -Raw).Replace( + '__MCP_URL__', $mcpPin[0].location).Replace('__MCP_VERSION__', $mcpPin[0].state.version) +$manifest | Set-Content (Join-Path $consumer 'Package.swift') +Copy-Item $shippingPath (Join-Path $consumer 'Package.resolved') +Copy-Item $shippingPath (Join-Path $evidence 'shipping-Package.resolved') +swift package --package-path $consumer resolve *> (Join-Path $evidence 'resolve.log') +if ($LASTEXITCODE -ne 0) { throw 'Host MCP consumer resolution failed' } +$consumerLock = Get-Content (Join-Path $consumer 'Package.resolved') -Raw | ConvertFrom-Json +foreach ($pin in $consumerLock.pins) { + $expected = @($shippingLock.pins | Where-Object { $_.identity -eq $pin.identity }) + if ($expected.Count -ne 1 -or $pin.location -ne $expected[0].location -or + $pin.state.revision -ne $expected[0].state.revision -or $pin.state.version -ne $expected[0].state.version) { + throw "Host MCP consumer differs from shipping dependency: $($pin.identity)" + } +} +if (@($consumerLock.pins | Where-Object { $_.identity -eq 'swift-sdk' }).Count -ne 1) { + throw 'Host MCP consumer did not resolve the shipping MCP dependency' +} +Copy-Item (Join-Path $fixture 'HostPipeTests.swift') $tests +Copy-Item (Join-Path $fixture 'HostPipeFixture.swift') $childSources +Copy-Item (Join-Path $fixture 'HostProcess') (Join-Path $consumer 'Sources/HostProcess') -Recurse +Copy-Item (Join-Path $consumer 'Package.swift') $evidence +$source = Join-Path $repository 'Sources/CodexAdapter/MCPInheritedPipeTransport.swift' +$copy = Join-Path $sources 'MCPInheritedPipeTransport.swift' +Copy-Item $source $copy +Copy-Item $source $childSources +$sourceHash = (Get-FileHash $source -Algorithm SHA256).Hash.ToLowerInvariant() +if ((Get-FileHash $copy -Algorithm SHA256).Hash.ToLowerInvariant() -ne $sourceHash -or + (Get-FileHash (Join-Path $childSources 'MCPInheritedPipeTransport.swift') -Algorithm SHA256).Hash.ToLowerInvariant() -ne $sourceHash) { + throw 'Host transport source copy changed' +} +[pscustomobject]@{ + adapterRevision = $adapterRevision + sdkRevision = $sdkRevision + mcpDependency = $mcpPin[0] + shippingLockSHA256 = $shippingHash.ToLowerInvariant() + source = 'Sources/CodexAdapter/MCPInheritedPipeTransport.swift' + sourceSHA256 = $sourceHash + evidenceClass = 'native-inherited-host-mcp-transport' + independentChild = $true + completeAdapter = $false + authenticatedModel = $false +} | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $evidence 'inputs.json') + +$versionOutput = swift --version +if ($LASTEXITCODE -ne 0) { throw 'Swift unavailable' } +$versionOutput | Set-Content (Join-Path $evidence 'toolchain.txt') +$version = (($versionOutput -join "`n") -split 'Swift version ')[1].Split(' ')[0].Trim() +if ($version -notmatch '^\d+\.\d+\.\d+$') { throw 'Cannot identify Swift testing runtime version' } +$developer = Split-Path (Split-Path $env:SDKROOT.TrimEnd([char[]]'\/')) +$testing = Join-Path $developer "Library/Testing-$version/usr/bin64" +$xctest = Join-Path $developer "Library/XCTest-$version/usr/bin64" +if (!(Test-Path (Join-Path $testing 'Testing.dll')) -or !(Test-Path (Join-Path $xctest 'XCTest.dll'))) { + throw 'Missing selected SDK testing runtimes' +} +$originalPath = $env:PATH +$originalFixture = $env:HOST_PIPE_FIXTURE_PATH +$results = @() +try { + $env:PATH = "$testing;$xctest;$originalPath" + foreach ($configuration in @('debug', 'release')) { + $buildLog = Join-Path $evidence "$configuration-fixture.log" + swift build --package-path $consumer --product HostPipeFixture -c $configuration --disable-automatic-resolution *> $buildLog + $fixtureCode = $LASTEXITCODE + if ($fixtureCode -ne 0) { + Get-Content $buildLog -Tail 60 + $results += [pscustomobject]@{ configuration = $configuration; fixtureExitCode = $fixtureCode; testExitCode = $fixtureCode } + $results | ConvertTo-Json | Set-Content (Join-Path $evidence 'results.json') + continue + } + $binaryPath = swift build --package-path $consumer --show-bin-path -c $configuration + if ($LASTEXITCODE -ne 0) { throw 'Cannot locate native child executable' } + $env:HOST_PIPE_FIXTURE_PATH = Join-Path ($binaryPath | Select-Object -Last 1) 'HostPipeFixture.exe' + if (!(Test-Path $env:HOST_PIPE_FIXTURE_PATH -PathType Leaf)) { throw 'Native child executable is missing' } + $log = Join-Path $evidence "$configuration-tests.log" + swift test --package-path $consumer --no-parallel -c $configuration --disable-automatic-resolution -Xswiftc -enable-testing *> $log + $code = $LASTEXITCODE + Get-Content $log -Tail 60 + $results += [pscustomobject]@{ configuration = $configuration; fixtureExitCode = $fixtureCode; testExitCode = $code } + $results | ConvertTo-Json | Set-Content (Join-Path $evidence 'results.json') + } +} finally { + $env:PATH = $originalPath + $env:HOST_PIPE_FIXTURE_PATH = $originalFixture + $lock = Join-Path $consumer 'Package.resolved' + if (Test-Path $lock) { Copy-Item $lock $evidence } +} +if ($results.Where({ $_.testExitCode -ne 0 }).Count -gt 0) { exit 1 } +if ((Get-FileHash $shippingPath -Algorithm SHA256).Hash -ne $shippingHash) { + throw 'Shipping dependency lock changed during host MCP acceptance' +} diff --git a/Scripts/test-windows-process.ps1 b/Scripts/test-windows-process.ps1 new file mode 100644 index 0000000..443d1a2 --- /dev/null +++ b/Scripts/test-windows-process.ps1 @@ -0,0 +1,119 @@ +param( + [Parameter(Mandatory = $true)][string]$SDKPath, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) + +$ErrorActionPreference = 'Stop' +$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$sdk = (Resolve-Path $SDKPath).Path +$sdkRevision = git -C $sdk rev-parse HEAD +if ($LASTEXITCODE -ne 0 -or $sdkRevision -cnotmatch '^[0-9a-f]{40}$') { throw 'Cannot identify SDK source' } +git -C $sdk diff --quiet HEAD +if ($LASTEXITCODE -ne 0) { throw 'Native process tests require an unchanged committed SDK candidate' } +$shippingLock = Get-Content (Join-Path $repository 'Package.resolved') -Raw | ConvertFrom-Json +$sdkPin = @($shippingLock.pins | Where-Object { $_.identity -eq 'swift-codex' }) +if ($sdkPin.Count -ne 1 -or $sdkPin[0].state.revision -ne $sdkRevision -or + $sdkPin[0].location -ne 'https://github.com/swift-library/swift-codex.git') { + throw 'Native process tests require the shipping SDK revision' +} +$adapterRevision = git -C $repository rev-parse HEAD +if ($LASTEXITCODE -ne 0) { throw 'Cannot identify adapter source' } +if (Test-Path $OutputDirectory) { throw 'Native process tests require a fresh output directory' } +New-Item -ItemType Directory -Path $OutputDirectory | Out-Null +$root = (Resolve-Path $OutputDirectory).Path +$evidence = Join-Path $root 'evidence' +$consumer = Join-Path $root 'consumer' +$sources = Join-Path $consumer 'Sources/ManagedProcess' +$tests = Join-Path $consumer 'Tests/ManagedProcessTests' +New-Item -ItemType Directory -Path $evidence, $sources, $tests | Out-Null +$fixture = Join-Path $repository 'Tests/WindowsProcess' +$manifest = (Get-Content (Join-Path $fixture 'Package.swift.template') -Raw).Replace( + '__SDK_PATH__', $sdk.Replace('\', '/').Replace('"', '\"')) +$manifest | Set-Content (Join-Path $consumer 'Package.swift') +Copy-Item (Join-Path $fixture 'ManagedProcessTests.swift') $tests +Copy-Item (Join-Path $fixture 'CommandRunnerTests.swift') $tests +Copy-Item (Join-Path $fixture 'PrivateDirectoryTests.swift') $tests +Copy-Item (Join-Path $fixture 'WorktreeFileSystemTests.swift') $tests +Copy-Item (Join-Path $consumer 'Package.swift') $evidence +$sourceRecords = @() +foreach ($name in @( + 'ManagedLineProcess.swift', 'ManagedLineProcess+Windows.swift', + 'CommandRunner.swift', 'CommandRunner+Windows.swift', 'WindowsCommandProcess.swift', + 'WindowsProcessJob.swift', 'WindowsFilePath.swift', 'WindowsExecutable.swift', + 'WindowsProcessEnvironment.swift', 'CodexConfig.swift', 'JSONValue.swift', + 'WindowsPrivateDirectory.swift', 'CodexWorktreeFileSystem.swift', 'CodexProcessEnvironment.swift' +)) { + $source = Join-Path $repository "Sources/CodexAdapter/$name" + Copy-Item $source $sources + $sourceRecords += [pscustomobject]@{ + source = "Sources/CodexAdapter/$name" + sha256 = (Get-FileHash $source -Algorithm SHA256).Hash.ToLowerInvariant() + } +} +[pscustomobject]@{ + sdkRevision = $sdkRevision + adapterRevision = $adapterRevision + sourceFiles = $sourceRecords + evidenceClass = 'native-adapter-lifecycle-using-complete-sdk-source' + authenticatedModel = $false + completeAdapter = $false +} | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $evidence 'inputs.json') + +$versionOutput = swift --version +if ($LASTEXITCODE -ne 0) { throw 'Swift unavailable' } +$versionOutput | Set-Content (Join-Path $evidence 'toolchain.txt') +$version = (($versionOutput -join "`n") -split 'Swift version ')[1].Split(' ')[0].Trim() +if ($version -notmatch '^\d+\.\d+\.\d+$') { throw 'Cannot identify Swift testing runtime version' } +$developer = Split-Path (Split-Path $env:SDKROOT.TrimEnd([char[]]'\/')) +$testing = Join-Path $developer "Library/Testing-$version/usr/bin64" +$xctest = Join-Path $developer "Library/XCTest-$version/usr/bin64" +if (!(Test-Path (Join-Path $testing 'Testing.dll')) -or !(Test-Path (Join-Path $xctest 'XCTest.dll'))) { + throw 'Missing selected SDK testing runtimes' +} +# These owners depend only on Foundation and WinSDK. Diagnose native API imports +# before building the complete SDK fixture and running both runtime configurations. +$filesystemSources = @('WindowsFilePath.swift', 'WindowsPrivateDirectory.swift', 'CodexWorktreeFileSystem.swift') | + ForEach-Object { Join-Path $sources $_ } +swiftc -typecheck -swift-version 6 -strict-concurrency=complete -module-name CodexNativeFileSystem @filesystemSources *> (Join-Path $evidence 'filesystem-typecheck.log') +$filesystemExit = $LASTEXITCODE +[pscustomobject]@{ exitCode = $filesystemExit } | ConvertTo-Json | Set-Content (Join-Path $evidence 'filesystem-typecheck.json') +if ($filesystemExit -ne 0) { + Get-Content (Join-Path $evidence 'filesystem-typecheck.log') + exit 1 +} + +$originalPath = $env:PATH +$originalFixture = $env:CODEX_WINDOWS_FIXTURE +$results = @() +try { + $env:PATH = "$testing;$xctest;$originalPath" + foreach ($configuration in @('debug', 'release')) { + $fixturePackage = Join-Path $sdk 'Tests/WindowsIntegration' + swift build --package-path $fixturePackage --product CodexProcessFixture -c $configuration *> (Join-Path $evidence "$configuration-fixture.log") + $fixtureExit = $LASTEXITCODE + $testExit = $null + if ($fixtureExit -eq 0) { + $binaryDirectory = swift build --package-path $fixturePackage -c $configuration --show-bin-path + if ($LASTEXITCODE -ne 0) { throw 'Cannot locate native fixture' } + $env:CODEX_WINDOWS_FIXTURE = Join-Path $binaryDirectory 'CodexProcessFixture.exe' + if (!(Test-Path $env:CODEX_WINDOWS_FIXTURE)) { throw 'Native fixture missing' } + swift test --package-path $consumer --no-parallel -c $configuration *> (Join-Path $evidence "$configuration-tests.log") + $testExit = $LASTEXITCODE + Get-Content (Join-Path $evidence "$configuration-tests.log") -Tail 55 + } else { + Get-Content (Join-Path $evidence "$configuration-fixture.log") -Tail 55 + } + $results += [pscustomobject]@{ + configuration = $configuration + fixtureExitCode = $fixtureExit + testExitCode = $testExit + } + $results | ConvertTo-Json | Set-Content (Join-Path $evidence 'results.json') + } +} finally { + $env:PATH = $originalPath + $env:CODEX_WINDOWS_FIXTURE = $originalFixture +} +if ($results.Where({ $_.fixtureExitCode -ne 0 -or $null -eq $_.testExitCode -or $_.testExitCode -ne 0 }).Count -gt 0) { + exit 1 +} diff --git a/Scripts/validate-windows-candidate.ps1 b/Scripts/validate-windows-candidate.ps1 new file mode 100644 index 0000000..9807e96 --- /dev/null +++ b/Scripts/validate-windows-candidate.ps1 @@ -0,0 +1,203 @@ +param( + [Parameter(Mandatory = $true)][string]$SDKRevision, + [switch]$DatabaseOnly +) + +$ErrorActionPreference = 'Stop' +if ($SDKRevision -cnotmatch '^[0-9a-f]{40}$') { throw 'Expected SDK must be an exact commit SHA' } +$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +Set-Location $repository +$root = Join-Path $repository '.build/windows-candidate' +if (Test-Path $root) { throw 'Windows source audit requires a fresh candidate directory' } +$evidence = Join-Path $root 'evidence' +New-Item -ItemType Directory -Path $evidence | Out-Null +swift --version | Out-File (Join-Path $evidence 'toolchain.txt') +if ($LASTEXITCODE -ne 0) { throw 'Swift toolchain unavailable' } + +$shippingHash = (Get-FileHash Package.resolved -Algorithm SHA256).Hash +$shippingLock = Get-Content Package.resolved -Raw | ConvertFrom-Json +$sdkPin = @($shippingLock.pins | Where-Object { $_.identity -eq 'swift-codex' }) +$mcpPin = @($shippingLock.pins | Where-Object { $_.identity -eq 'swift-sdk' }) +if ($sdkPin.Count -ne 1 -or $sdkPin[0].state.revision -ne $SDKRevision -or + $sdkPin[0].location -ne 'https://github.com/swift-library/swift-codex.git' -or + $mcpPin.Count -ne 1 -or $mcpPin[0].location -ne 'https://github.com/computer-mcp/swift-sdk.git') { + throw 'Windows acceptance requires the declared shipping SDK and MCP graph' +} +Copy-Item Package.resolved (Join-Path $evidence 'shipping-Package.resolved') +swift package resolve *> (Join-Path $evidence 'resolve.log') +if ($LASTEXITCODE -ne 0) { throw 'Shipping dependency resolution failed' } +if ((Get-FileHash Package.resolved -Algorithm SHA256).Hash -ne $shippingHash) { + throw 'Shipping dependency lock changed' +} +foreach ($pin in @($sdkPin[0], $mcpPin[0])) { + $checkout = Join-Path $repository ".build/checkouts/$($pin.identity)" + $revision = git -C $checkout rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $revision -ne $pin.state.revision) { + throw "Resolved dependency revision differs: $($pin.identity)" + } + git -C $checkout diff --quiet HEAD + if ($LASTEXITCODE -ne 0) { throw "Resolved dependency source changed: $($pin.identity)" } +} +$adapterRevision = git rev-parse HEAD +[pscustomobject]@{ + adapterRevision = $adapterRevision + sdkRevision = $sdkPin[0].state.revision + mcpDependency = $mcpPin[0] + shippingLockSHA256 = $shippingHash.ToLowerInvariant() + evidenceClass = if ($DatabaseOnly) { 'native-database-lifetime' } else { 'native-shipping-dependency-build' } + completeAdapter = -not $DatabaseOnly.IsPresent + shippingDependenciesChanged = $false +} | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $evidence 'inputs.json') + +$sqlite = & (Join-Path $PSScriptRoot 'build-windows-sqlite.ps1') -OutputDirectory (Join-Path $evidence 'sqlite') +$buildArguments = @('--disable-automatic-resolution', '-Xcc', "-I$($sqlite.includeDirectory)", '-Xlinker', "/LIBPATH:$($sqlite.libraryDirectory)") +$results = @() +$targets = if ($DatabaseOnly) { @('GRDB') } else { + @('GRDB', 'Subprocess', 'ArgumentParser', 'MCP', 'CodexAppServerClient', 'CodexExec', 'CodexAdapter', 'CodexMCPAdapter') +} +foreach ($target in $targets) { + $log = Join-Path $evidence "$target.log" + swift build --target $target @buildArguments *> $log + $code = $LASTEXITCODE + Get-Content $log -Tail 50 + $results += [pscustomobject]@{ target = $target; exitCode = $code } + $results | ConvertTo-Json | Set-Content (Join-Path $evidence 'results.json') +} +Copy-Item Package.resolved (Join-Path $evidence 'candidate-Package.resolved') + +$runtimeExit = $null +if (!$DatabaseOnly -and $results.Where({ $_.target -eq 'CodexMCPAdapter' -and $_.exitCode -eq 0 }).Count -eq 1) { + # Target compilation does not link an executable or prove resource lookup. + swift build --product codex-mcp-adapter @buildArguments *> (Join-Path $evidence 'adapter-link.log') + $linkExit = $LASTEXITCODE + $runtimeExit = $linkExit + [pscustomobject]@{ configuration = 'debug'; linkExitCode = $linkExit } | + ConvertTo-Json | Set-Content (Join-Path $evidence 'linked-product-results.json') + Get-Content (Join-Path $evidence 'adapter-link.log') -Tail 50 + if ($linkExit -eq 0) { + $built = swift build --show-bin-path + if ($LASTEXITCODE -ne 0) { throw 'Cannot locate linked adapter' } + $product = Join-Path $evidence 'linked-product' + New-Item -ItemType Directory -Path $product | Out-Null + $binary = Join-Path $product 'codex-mcp-adapter.exe' + Copy-Item (Join-Path $built 'codex-mcp-adapter.exe') $binary + $resources = @(Get-ChildItem $built -Directory | Where-Object { $_.Name -match '\.(resources|bundle)$' }) + if ($resources.Count -eq 0) { throw 'Missing SwiftPM resource directory' } + foreach ($resource in $resources) { Copy-Item $resource.FullName $product -Recurse } + $inventory = @(Get-ChildItem $product -File -Recurse | ForEach-Object { + [pscustomobject]@{ + path = [System.IO.Path]::GetRelativePath($product, $_.FullName) + bytes = $_.Length + sha256 = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } + }) + $inventory | ConvertTo-Json | Set-Content (Join-Path $evidence 'linked-product-files.json') + $inspector = Get-Command llvm-readobj -ErrorAction SilentlyContinue + if ($inspector) { + & $inspector.Source --coff-imports $binary *> (Join-Path $evidence 'adapter-imports.txt') + if ($LASTEXITCODE -ne 0) { throw 'Native import inspection failed' } + } + & $binary --help *> (Join-Path $evidence 'adapter-help.txt') + if ($LASTEXITCODE -ne 0) { throw 'Relocated native adapter help failed' } + & $binary --version *> (Join-Path $evidence 'adapter-version.txt') + if ($LASTEXITCODE -ne 0) { throw 'Relocated native adapter version failed' } + + $fixture = Join-Path $repository 'Tests/WindowsAdapter' + $codexMetadata = Get-Content (Join-Path $fixture 'codex-binary.json') -Raw | ConvertFrom-Json + Copy-Item (Join-Path $fixture 'codex-binary.json') $evidence + $codexDirectory = Join-Path $root 'codex-binary' + New-Item -ItemType Directory -Path $codexDirectory | Out-Null + $codexArchive = Join-Path $codexDirectory 'codex.zip' + Invoke-WebRequest -Uri $codexMetadata.archiveURL -OutFile $codexArchive -TimeoutSec 120 -MaximumRetryCount 2 + if ((Get-FileHash $codexArchive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $codexMetadata.archiveSHA256) { + throw 'Native Codex archive checksum mismatch' + } + Expand-Archive -Path $codexArchive -DestinationPath $codexDirectory + $codexBinary = Join-Path $codexDirectory $codexMetadata.executable + if ((Get-FileHash $codexBinary -Algorithm SHA256).Hash.ToLowerInvariant() -ne $codexMetadata.executableSHA256) { + throw 'Native Codex executable checksum mismatch' + } + $codexVersion = & $codexBinary --version + if ($LASTEXITCODE -ne 0 -or $codexVersion -ne "codex-cli $($codexMetadata.version)") { + throw 'Unexpected native Codex version' + } + [pscustomobject]@{ + version = $codexVersion + executableSHA256 = (Get-FileHash $codexBinary -Algorithm SHA256).Hash.ToLowerInvariant() + installed = $false + bundled = $false + } | ConvertTo-Json | Set-Content (Join-Path $evidence 'codex-binary-receipt.json') + python (Join-Path $fixture 'ProtocolCheck.py') --adapter $binary --codex $codexBinary ` + --evidence-directory (Join-Path $evidence 'adapter-protocol') ` + *> (Join-Path $evidence 'adapter-protocol.log') + $runtimeExit = $LASTEXITCODE + Get-Content (Join-Path $evidence 'adapter-protocol.log') -Tail 60 + [pscustomobject]@{ + configuration = 'debug' + linkExitCode = $linkExit + protocolExitCode = $runtimeExit + runtimeEnvironment = 'selected-toolchain' + authenticatedModel = $false + cleanMachineRelocation = $false + } | ConvertTo-Json | Set-Content (Join-Path $evidence 'linked-product-results.json') + } +} + +$databaseExit = $null +if ($results.Where({ $_.target -eq 'GRDB' -and $_.exitCode -eq 0 }).Count -eq 1) { + $consumer = Join-Path $root 'database-consumer' + $tests = Join-Path $consumer 'Tests/DatabaseTests' + New-Item -ItemType Directory -Path $tests | Out-Null + $grdb = (Resolve-Path '.build/checkouts/GRDB.swift').Path + $grdbRevision = git -C $grdb rev-parse HEAD + if ($LASTEXITCODE -ne 0) { throw 'Cannot identify resolved GRDB source' } + $lock = Get-Content Package.resolved -Raw | ConvertFrom-Json + $grdbPin = @($lock.pins | Where-Object { $_.identity -eq 'grdb.swift' }) + if ($grdbPin.Count -ne 1 -or $grdbPin[0].state.revision -ne $grdbRevision) { + throw 'GRDB consumer source differs from the resolved lock' + } + $fixture = Join-Path $repository 'Tests/WindowsSQLite' + $manifest = (Get-Content (Join-Path $fixture 'Package.swift.template') -Raw).Replace( + '__GRDB_PATH__', $grdb.Replace('\', '/')) + $manifest | Set-Content (Join-Path $consumer 'Package.swift') + Copy-Item (Join-Path $fixture 'GRDBTests.swift') $tests + Copy-Item (Join-Path $fixture 'DirectoryLifetimeTests.swift') $tests + $directorySources = @() + foreach ($name in @('WindowsPrivateDirectory.swift', 'WindowsFilePath.swift')) { + $source = Join-Path $repository "Sources/CodexAdapter/$name" + Copy-Item $source $tests + $directorySources += [pscustomobject]@{ + source = "Sources/CodexAdapter/$name" + sha256 = (Get-FileHash $source -Algorithm SHA256).Hash.ToLowerInvariant() + } + } + $directorySources | ConvertTo-Json | Set-Content (Join-Path $evidence 'database-directory-sources.json') + $grdbRevision | Set-Content (Join-Path $evidence 'grdb-revision.txt') + Copy-Item (Join-Path $consumer 'Package.swift') (Join-Path $evidence 'database-Package.swift') + + $version = ((Get-Content (Join-Path $evidence 'toolchain.txt') -Raw) -split 'Swift version ')[1].Split(' ')[0].Trim() + if ($version -notmatch '^\d+\.\d+\.\d+$') { throw 'Cannot identify Swift testing runtime version' } + $developer = Split-Path (Split-Path $env:SDKROOT.TrimEnd([char[]]'\/')) + $testing = Join-Path $developer "Library/Testing-$version/usr/bin64" + $xctest = Join-Path $developer "Library/XCTest-$version/usr/bin64" + if (!(Test-Path (Join-Path $testing 'Testing.dll')) -or !(Test-Path (Join-Path $xctest 'XCTest.dll'))) { + throw 'Missing selected SDK testing runtimes' + } + $originalPath = $env:PATH + try { + $env:PATH = "$testing;$xctest;$originalPath" + swift package --package-path $consumer resolve *> (Join-Path $evidence 'grdb-resolve.log') + if ($LASTEXITCODE -ne 0) { throw 'GRDB consumer resolution failed' } + swift test --package-path $consumer --no-parallel @buildArguments *> (Join-Path $evidence 'grdb-tests.log') + $databaseExit = $LASTEXITCODE + Get-Content (Join-Path $evidence 'grdb-tests.log') -Tail 50 + } finally { + $env:PATH = $originalPath + } +} +[pscustomobject]@{ grdbTestExitCode = $databaseExit } | ConvertTo-Json | Set-Content (Join-Path $evidence 'database-results.json') +if ((Get-FileHash Package.resolved -Algorithm SHA256).Hash -ne $shippingHash) { + throw 'Shipping dependency lock changed during native acceptance' +} +if ($results.Where({ $_.exitCode -ne 0 }).Count -gt 0 -or $databaseExit -ne 0 -or + (!$DatabaseOnly -and ($null -eq $runtimeExit -or $runtimeExit -ne 0))) { exit 1 } diff --git a/Scripts/windows-sqlite.json b/Scripts/windows-sqlite.json new file mode 100644 index 0000000..970da8e --- /dev/null +++ b/Scripts/windows-sqlite.json @@ -0,0 +1,14 @@ +{ + "version": "3.53.4", + "url": "https://www.sqlite.org/2026/sqlite-amalgamation-3530400.zip", + "size": 2946650, + "sha256": "1e71ddf93849c6a6ecf58b827c0692073d2dd7ee40196158068f7b29f422e87d", + "sha3_256": "628a44cfe82c66aed1ccbbe85a562d2e33ebe64b3288981ed76285612227934e", + "directory": "sqlite-amalgamation-3530400", + "compileDefinitions": [ + "SQLITE_THREADSAFE=1", + "SQLITE_ENABLE_FTS5", + "SQLITE_ENABLE_SNAPSHOT", + "SQLITE_ENABLE_COLUMN_METADATA" + ] +} diff --git a/Scripts/windows_package.py b/Scripts/windows_package.py new file mode 100644 index 0000000..e59fbd6 --- /dev/null +++ b/Scripts/windows_package.py @@ -0,0 +1,99 @@ +"""Stage inspected Windows runtime inputs and their original source notices.""" + +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil + +import windows_runtime + + +def notice_sources(root): + metadata = json.loads((root / "sources.json").read_text(encoding="utf-8")) + if metadata.get("schema_version") != 1 or not metadata.get("sources"): + raise ValueError("Missing Windows runtime notice provenance") + names = set() + for source in metadata["sources"]: + name = source["file"] + if not isinstance(name, str): + raise ValueError("Invalid Windows runtime notice path") + path = PurePosixPath(name) + if (path.is_absolute() or path.as_posix() != name + or any(part in {"", ".", ".."} for part in name.split("/")) + or ":" in name or "\\" in name or name.casefold() in names): + raise ValueError("Invalid Windows runtime notice path") + names.add(name.casefold()) + local = root.joinpath(*path.parts) + windows_runtime.regular_file(local) + if local.stat().st_size != source["bytes"] or windows_runtime.digest(local) != source["sha256"]: + raise ValueError(f"Windows runtime notice differs from its upstream source: {name}") + coverage = metadata.get("runtime_libraries", {}) + if not coverage: + raise ValueError("Missing runtime library notice mapping") + for name, declaration in coverage.items(): + if not re.fullmatch(r"[a-z0-9_.-]+\.dll", name): + raise ValueError("Invalid runtime library notice name") + scope = declaration.get("license_scope") + sources = declaration.get("sources") + if (not isinstance(sources, list) + or any(not isinstance(source, str) or source.casefold() not in names for source in sources) + or scope not in {"open-source", "Microsoft Visual C++ Redistributable"} + or (scope == "open-source" and not sources)): + raise ValueError("Runtime library has incomplete notice coverage") + return metadata + + +def stage_runtime(repo, binary_directory, notices, sqlite, swift, command, copy_file, copy_tree): + notice_root = repo / "Vendor/SwiftWindowsRuntime" + metadata = notice_sources(notice_root) + version = re.search(r"Swift version (\d+\.\d+\.\d+)", command([swift, "--version"], repo)) + if version is None or version[1] != metadata["swift_release"]: + raise ValueError("Swift runtime version must match the reviewed notice provenance") + runtime_directories = [] + for entry in os.environ.get("PATH", "").split(os.pathsep): + directory = Path(entry) + if entry and (directory / "swiftCore.dll").is_file() and directory not in runtime_directories: + runtime_directories.append(directory) + inspector = shutil.which("llvm-readobj") + if not runtime_directories or inspector is None: + raise ValueError("Selected Swift runtime directories and llvm-readobj are required") + report = windows_runtime.audit(binary_directory / "codex-mcp-adapter.exe", runtime_directories, + Path(os.environ["SystemRoot"]) / "System32", Path(inspector)) + if any(row["role"] == "external-msvc-runtime" for row in report["libraries"]): + raise ValueError("Runtime inputs must come from the selected toolchain, not System32 redistributables") + coverage = metadata["runtime_libraries"] + copied = [] + for row in report["libraries"]: + if row["role"] != "runtime": + continue + name = row["name"].casefold() + declaration = coverage.get(name) + if declaration is None: + raise ValueError(f"Runtime library lacks reviewed notice coverage: {row['name']}") + source = Path(row["path"]) + target = binary_directory / source.name + if os.path.lexists(target): + raise ValueError("Runtime DLL collides with the product payload") + copy_file(source, target) + if windows_runtime.digest(target) != row["sha256"]: + raise ValueError("Copied runtime DLL differs from its inspected source") + copied.append({"file": target.name, "sha256": row["sha256"], "bytes": row["bytes"], + "notice_coverage": declaration}) + if not any(row["file"].casefold() == "swiftcore.dll" for row in copied): + raise ValueError("The native adapter must include its Swift runtime") + copy_tree(notice_root, notices / "windows-runtime") + # The original SQLite header retains its public-domain statement verbatim. + sqlite_notices = notices / "sqlite" + sqlite_notices.mkdir() + header = Path(sqlite["includeDirectory"]) / "sqlite3.h" + if windows_runtime.digest(header) != sqlite["receipt"]["headerSHA256"]: + raise ValueError("SQLite notice input differs from the compiled source") + copy_file(header, sqlite_notices / header.name) + copy_file(repo / "Scripts/windows-sqlite.json", sqlite_notices / "source.json") + return {"architecture": {"aarch64": "arm64", "x86_64": "x86_64"}[report["architecture"]], + "swift_version": version[1], "libraries": copied, + "sqlite_source": sqlite["receipt"]["source"], + "sqlite_library_sha256": sqlite["receipt"]["librarySHA256"], + "notice_metadata_sha256": windows_runtime.digest(notice_root / "sources.json"), + "system_imports": [row["name"] for row in report["libraries"] if row["role"] != "runtime"]} diff --git a/Scripts/windows_runtime.py b/Scripts/windows_runtime.py new file mode 100644 index 0000000..a38ccbb --- /dev/null +++ b/Scripts/windows_runtime.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Inspect a Windows executable's recursive runtime imports without loading it.""" + +import argparse +import ctypes +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess + + +def digest(path): + with path.open("rb") as source: + return hashlib.file_digest(source, "sha256").hexdigest() + + +def regular_file(path): + metadata = path.lstat() + if (not stat.S_ISREG(metadata.st_mode) + or getattr(metadata, "st_file_attributes", 0) & 0x400): + raise ValueError(f"Runtime input is a link or special file: {path}") + + +def imports(path, inspector): + regular_file(path) + result = subprocess.run([str(inspector), "--file-headers", "--coff-imports", str(path)], + capture_output=True, text=True, check=True, timeout=60) + architecture = re.search(r"^Arch: (\S+)$", result.stdout, re.MULTILINE) + if architecture is None or architecture[1] not in {"x86_64", "aarch64"}: + raise ValueError(f"Unsupported or missing PE architecture: {path}") + names = re.findall(r"^ Name: (.+)$", result.stdout, re.MULTILINE) + for name in names: + if not re.fullmatch(r"[A-Za-z0-9_.-]+\.dll", name, re.IGNORECASE): + raise ValueError(f"Invalid DLL import: {name!r}") + return architecture[1], sorted(set(names), key=str.casefold) + + +def directory_files(directory): + metadata = directory.lstat() + if (not stat.S_ISDIR(metadata.st_mode) + or getattr(metadata, "st_file_attributes", 0) & 0x400): + raise ValueError(f"Runtime directory is a link or special file: {directory}") + result = {} + for path in directory.iterdir(): + if path.suffix.lower() == ".dll": + key = path.name.casefold() + if key in result: + raise ValueError(f"Case-colliding DLL names in {directory}: {path.name}") + result[key] = path + return result + + +def resolve(name, runtime_files, system_files): + key = name.casefold() + # API-set contracts are resolved by Windows, not independent redistributable files. + if key.startswith(("api-ms-win-", "ext-ms-win-")): + return "windows-api-set", None + candidates = [files[key] for files in runtime_files if key in files] + if candidates: + for path in candidates: + regular_file(path) + if len({digest(path) for path in candidates}) != 1: + raise ValueError(f"Conflicting runtime DLL sources for {name}: {candidates}") + return "runtime", candidates[0] + if key in system_files: + # Visual C++ redistributables are not Windows OS components, even in System32. + role = ("external-msvc-runtime" if re.match(r"(?:vcruntime|msvcp|concrt)\d", key) + else "windows-system") + regular_file(system_files[key]) + return role, system_files[key] + raise ValueError(f"Unresolved DLL import: {name}") + + +def audit(executable, runtime_directories, system_directory, inspector): + runtime_files = [directory_files(path) for path in runtime_directories] + system_files = directory_files(system_directory) + architecture, direct = imports(executable, inspector) + queue = [(name, executable.name) for name in direct] + libraries = {} + while queue: + name, owner = queue.pop(0) + key = name.casefold() + if key in libraries: + if owner not in libraries[key]["imported_by"]: + libraries[key]["imported_by"].append(owner) + continue + role, path = resolve(name, runtime_files, system_files) + entry = {"name": name, "role": role, "imported_by": [owner]} + libraries[key] = entry + if path is not None: + entry.update(path=str(path), bytes=path.stat().st_size, sha256=digest(path)) + if role in {"runtime", "external-msvc-runtime"}: + native_arch, dependencies = imports(path, inspector) + if native_arch != architecture: + raise ValueError(f"Runtime architecture mismatch: {path}: {native_arch} != {architecture}") + entry.update(architecture=native_arch, imports=dependencies) + queue.extend((dependency, name) for dependency in dependencies) + return {"executable": str(executable), "sha256": digest(executable), + "architecture": architecture, "runtime_directories": list(map(str, runtime_directories)), + "system_directory": str(system_directory), "inspector": str(inspector), + "imports": direct, "libraries": sorted(libraries.values(), key=lambda row: row["name"].casefold()), + "evidence_class": "static-native-import-closure", "relocation_verified": False, + "dynamic_loads_verified": False, "distribution_notices_verified": False} + + +def loaded_modules(pid): + """Observe the live process through a retained handle, independent of PATH.""" + if os.name != "nt": + raise ValueError("Native module observation requires Windows") + from ctypes import wintypes + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel.OpenProcess.restype = wintypes.HANDLE + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + kernel.CloseHandle.restype = wintypes.BOOL + kernel.K32EnumProcessModulesEx.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.HMODULE), + wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.DWORD] + kernel.K32EnumProcessModulesEx.restype = wintypes.BOOL + kernel.K32GetModuleFileNameExW.argtypes = [wintypes.HANDLE, wintypes.HMODULE, + wintypes.LPWSTR, wintypes.DWORD] + kernel.K32GetModuleFileNameExW.restype = wintypes.DWORD + handle = kernel.OpenProcess(0x0400 | 0x0010, False, pid) # QUERY_INFORMATION | VM_READ + if not handle: + raise ctypes.WinError(ctypes.get_last_error()) + try: + count = 128 + for _ in range(4): + modules = (wintypes.HMODULE * count)() + needed = wintypes.DWORD() + if not kernel.K32EnumProcessModulesEx(handle, modules, ctypes.sizeof(modules), + ctypes.byref(needed), 3): + raise ctypes.WinError(ctypes.get_last_error()) + if needed.value <= ctypes.sizeof(modules): + break + count = needed.value // ctypes.sizeof(wintypes.HMODULE) + 32 + if count > 4096: + raise ValueError("Native module inventory exceeds the observation bound") + else: + raise ValueError("Native module inventory changed during observation") + paths = [] + for module in modules[:needed.value // ctypes.sizeof(wintypes.HMODULE)]: + buffer = ctypes.create_unicode_buffer(32768) + length = kernel.K32GetModuleFileNameExW(handle, module, buffer, len(buffer)) + if not length: + raise ctypes.WinError(ctypes.get_last_error()) + if length >= len(buffer): + raise ValueError("Native module path was truncated") + paths.append(Path(buffer.value)) + return paths + finally: + kernel.CloseHandle(handle) + + +def verify_app_local_modules(modules, executable, system_directory): + local = directory_files(executable.parent) + observed = set() + result = [] + for path in modules: + name = path.name.casefold() + regular_file(path) + if name in local: + if not path.samefile(local[name]): + raise ValueError(f"Packaged runtime was loaded from outside the package: {path}") + role = "app-local-runtime" + observed.add(name) + elif path.samefile(executable): + role = "adapter" + elif path.parent.samefile(system_directory): + role = "windows-system" + else: + raise ValueError(f"Process loaded an undeclared external module: {path}") + result.append({"path": str(path), "role": role, "sha256": digest(path)}) + if "swiftcore.dll" not in observed: + raise ValueError("No app-local Swift runtime was observed") + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--executable", required=True, type=Path) + parser.add_argument("--runtime-directory", action="append", required=True, type=Path) + parser.add_argument("--system-directory", required=True, type=Path) + parser.add_argument("--inspector", type=Path, default=shutil.which("llvm-readobj")) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + if args.inspector is None: + parser.error("llvm-readobj is required") + report = audit(args.executable, args.runtime_directory, args.system_directory, args.inspector) + with args.output.open("x", encoding="utf-8") as output: + json.dump(report, output, indent=2) + output.write("\n") + print(json.dumps({"architecture": report["architecture"], "libraries": len(report["libraries"]), + "report": str(args.output)})) diff --git a/Sources/CodexAdapter/AppServerSchema.swift b/Sources/CodexAdapter/AppServerSchema.swift index 92f30ee..4070701 100644 --- a/Sources/CodexAdapter/AppServerSchema.swift +++ b/Sources/CodexAdapter/AppServerSchema.swift @@ -1,7 +1,12 @@ import CodexAppServerRuntime -import CryptoKit import Foundation +#if canImport(CryptoKit) + import CryptoKit +#else + import Crypto +#endif + typealias AppServerJSON = CodexAppServerConnectionFoundation.JSONValue enum SchemaError: Error, Equatable, LocalizedError { diff --git a/Sources/CodexAdapter/CodexAdapterBuildInfo.swift b/Sources/CodexAdapter/CodexAdapterBuildInfo.swift index 3c26e33..6bde9fc 100644 --- a/Sources/CodexAdapter/CodexAdapterBuildInfo.swift +++ b/Sources/CodexAdapter/CodexAdapterBuildInfo.swift @@ -1,4 +1,4 @@ // Generated by Scripts/version.py from computer-mcp-plugin.toml. package enum CodexAdapterBuildInfo { - package static let version = "0.2.1" + package static let version = "0.3.0" } diff --git a/Sources/CodexAdapter/CodexAdapterServer.swift b/Sources/CodexAdapter/CodexAdapterServer.swift index 50176ba..55135bd 100644 --- a/Sources/CodexAdapter/CodexAdapterServer.swift +++ b/Sources/CodexAdapter/CodexAdapterServer.swift @@ -44,27 +44,49 @@ package enum CodexAdapterServer { appServer: CodexAppServerProvider? = nil ) async throws { let tools = ProtocolTools(inventory: try .bundled()) + if appServer != nil { try CodexAppServerMethodCatalog.validate() } + let work = CodexWorkSnapshot { + let executionWork = try await execution.exec?.workResources() ?? [] + let appWork = try await appServer?.appServer.workResources() ?? [] + return executionWork + appWork + } let server = MCP.Server( name: "codex-mcp-adapter", version: CodexAdapterBuildInfo.version, instructions: "Execution tools retain their Codex session and call identifiers. Protocol declarations describe schemas, not execution support.", - capabilities: .init(tools: .init())) + capabilities: .init(resources: .init(subscribe: false, listChanged: false), tools: .init())) await server.withMethodHandler(MCP.ListTools.self) { params in guard params.cursor == nil else { throw MCPError.invalidParams("Unknown tools cursor.") } return MCP.ListTools.Result( - tools: ProtocolTools.definitions + execution.tools + (appServer?.tools ?? [])) + tools: try (ProtocolTools.definitions + execution.tools + (appServer?.tools ?? [])) + .map(CodexWorkSnapshot.declaring)) + } + await server.withMethodHandler(MCP.ListResources.self) { params in + guard params.cursor == nil else { throw MCPError.invalidParams("Unknown resources cursor.") } + return .init(resources: [ + .init(name: "Runtime work", uri: CodexWorkSnapshot.uri, mimeType: "application/json") + ]) + } + await server.withMethodHandler(MCP.ReadResource.self) { params in + guard params.uri == CodexWorkSnapshot.uri else { + throw MCPError.invalidParams("Unknown resource URI.") + } + return try await work.read() } await server.withMethodHandler(MCP.CallTool.self) { params in + let invocation = try CodexWorkInvocation.parse(params._meta) if ProtocolTools.definitions.contains(where: { $0.name == params.name }) { return try tools.call(name: params.name, arguments: params.arguments ?? [:]) } let arguments = try JSONDecoder().decode( JSONValue.self, from: JSONEncoder().encode(params.arguments ?? [:])) do { - if let appServer, appServer.tools.contains(where: { $0.name == params.name }) { - return try await appServer.call(name: params.name, arguments: arguments) + return try await CodexWorkInvocation.$current.withValue(invocation) { + if let appServer, appServer.tools.contains(where: { $0.name == params.name }) { + return try await appServer.call(name: params.name, arguments: arguments) + } + return try await execution.call(name: params.name, arguments: arguments) } - return try await execution.call(name: params.name, arguments: arguments) } catch CodexToolError.unknownTool { throw MCPError.invalidParams("Unknown tool: \(params.name)") } catch { @@ -82,11 +104,13 @@ package enum CodexAdapterServer { await server.waitUntilCompleted() } catch { await server.stop() + await work.shutdown() await appServer?.shutdown() await execution.shutdown() throw error } await server.stop() + await work.shutdown() await appServer?.shutdown() await execution.shutdown() } diff --git a/Sources/CodexAdapter/CodexAppAsyncWork.swift b/Sources/CodexAdapter/CodexAppAsyncWork.swift new file mode 100644 index 0000000..aed3521 --- /dev/null +++ b/Sources/CodexAdapter/CodexAppAsyncWork.swift @@ -0,0 +1,185 @@ +import Foundation + +/// Native asynchronous starts acknowledge admission, not background completion. +struct CodexAppAsyncWork: Sendable { + private enum Scope: Hashable, Sendable { + case account + case mcp(name: String, threadID: String?) + case realtime(threadID: String) + + var kind: String { + switch self { + case .account: "codex.app.login" + case .mcp: "codex.app.mcp-login" + case .realtime: "codex.app.realtime" + } + } + } + + struct Ticket: Sendable { + let id: UUID + } + + private struct Entry: Sendable { + let scope: Scope + let generation: Int + let binding: CodexWorkBinding + var awaitingReply = true + var loginID: String? + var completed = false + var earlyCompletions: Set = [] + var state: CodexWorkResource.State = .active + } + + private var entries: [UUID: Entry] = [:] + private var untrackedGenerations: Set = [] + var isEmpty: Bool { entries.isEmpty && untrackedGenerations.isEmpty } + + func hasWork(threadID: String) -> Bool { + entries.values.contains { entry in + switch entry.scope { + case .account: false + case .mcp(_, let id): id == threadID + case .realtime(let id): id == threadID + } + } + } + + mutating func prepare(method: String, params: JSONValue?, generation: Int) throws -> Ticket? { + let scope: Scope + let params = params?.objectValue ?? [:] + switch method { + case "account/login/start": + guard let type = params["type"]?.stringValue, + ["chatgpt", "chatgptDeviceCode"].contains(type) + else { return nil } + scope = .account + case "mcpServer/oauth/login": + guard let name = params["name"]?.stringValue else { return nil } + scope = .mcp(name: name, threadID: params["threadId"]?.stringValue) + case "thread/realtime/start": + guard let threadID = params["threadId"]?.stringValue else { return nil } + scope = .realtime(threadID: threadID) + default: return nil + } + // MCP and realtime completion have no attempt ID. A second start on the same scope + // cannot be correlated safely until the first completion is observed. + let conflicts = entries.values.contains { entry in + guard entry.scope == scope else { return false } + return scope != .account || entry.awaitingReply + } + guard !conflicts, entries.count < 256 else { + throw CodexToolError.disabled( + "codex.app.async_work_busy: Native background work is still settling or capacity is reached." + ) + } + let ticket = Ticket(id: UUID()) + entries[ticket.id] = .init( + scope: scope, generation: generation, + binding: .init(origin: .init(invocation: CodexWorkInvocation.current))) + return ticket + } + + mutating func replied(_ ticket: Ticket?, response: JSONValue) { + guard let ticket, var entry = entries[ticket.id] else { return } + entry.awaitingReply = false + if entry.scope == .account { + guard let loginID = response.objectValue?["loginId"]?.stringValue else { + // An undecodable acknowledgement cannot prove whether a login task exists. + entry.state = .uncertain + entries[ticket.id] = entry + return + } + entry.loginID = loginID + entry.completed = entry.earlyCompletions.contains(loginID) + entry.earlyCompletions.removeAll() + } + if entry.completed { + entries.removeValue(forKey: ticket.id) + } else { + entries[ticket.id] = entry + } + } + + mutating func failed(_ ticket: Ticket?, rejected: Bool) { + guard let ticket, entries[ticket.id] != nil else { return } + if rejected { + entries.removeValue(forKey: ticket.id) + } else { + entries[ticket.id]?.state = .uncertain + } + } + + mutating func notified(method: String, params: [String: JSONValue], generation: Int) { + if method == "thread/realtime/started", let threadID = params["threadId"]?.stringValue { + let scope = Scope.realtime(threadID: threadID) + if !entries.values.contains(where: { $0.scope == scope && $0.generation == generation }) { + guard entries.count < 256, threadID.utf8.count <= 1_024 else { + untrackedGenerations.insert(generation) + return + } + entries[UUID()] = .init( + scope: scope, generation: generation, binding: .init(), awaitingReply: false) + } + return + } + guard + ["account/login/completed", "mcpServer/oauthLogin/completed", "thread/realtime/closed"] + .contains(method) + else { return } + for id in entries.keys { + guard var entry = entries[id], entry.generation == generation else { continue } + switch entry.scope { + case .account: + guard method == "account/login/completed", + let loginID = params["loginId"]?.stringValue, loginID.utf8.count <= 1_024 + else { continue } + if entry.awaitingReply { + if entry.earlyCompletions.count < 256 { entry.earlyCompletions.insert(loginID) } + } else if entry.loginID == loginID { + entry.completed = true + } + case .mcp(let name, let threadID): + guard method == "mcpServer/oauthLogin/completed", + params["name"]?.stringValue == name, params["threadId"]?.stringValue == threadID + else { continue } + entry.completed = true + case .realtime(let threadID): + guard method == "thread/realtime/closed", params["threadId"]?.stringValue == threadID else { + continue + } + entry.completed = true + } + if entry.completed && !entry.awaitingReply { + entries.removeValue(forKey: id) + } else { + entries[id] = entry + } + } + } + + func workResources() throws -> [CodexWorkResource] { + guard untrackedGenerations.isEmpty else { + throw CodexToolError.executionFailed( + "codex.app.work_unavailable: Native background work exceeds tracked capacity.") + } + return try entries.values.map { entry in + var handles: [String: JSONValue] = [:] + switch entry.scope { + case .account: + if let id = entry.loginID { handles["login_id"] = .string(id) } + case .mcp(let name, let threadID): + handles["name"] = .string(name) + if let threadID { handles["thread_id"] = .string(threadID) } + case .realtime(let threadID): + handles["thread_id"] = .string(threadID) + } + return try entry.binding.resource(entry.scope.kind, state: entry.state, handles: handles) + } + } + + mutating func retired(generation: Int) { + entries = entries.filter { $0.value.generation != generation } + untrackedGenerations.remove(generation) + } +} diff --git a/Sources/CodexAdapter/CodexAppServerMethodCatalog.swift b/Sources/CodexAdapter/CodexAppServerMethodCatalog.swift new file mode 100644 index 0000000..e5157dd --- /dev/null +++ b/Sources/CodexAdapter/CodexAppServerMethodCatalog.swift @@ -0,0 +1,181 @@ +import CodexAppServerProtocol +import Foundation +import MCP + +struct CodexAppServerMethod: Equatable, Sendable { + let method: String + let description: String + let takesParams: Bool + let risk: CodexOperationRisk + var channel: ProtocolInventory.Channel = .stable + var parameterSchema: JSONValue? = nil + var parametersRequired = false + var threadParameters: [String: Bool] = [:] + + var toolName: String { "codex.app.native." + method.replacingOccurrences(of: "/", with: ".") } + + func validate(params: JSONValue?) throws { + var request: [String: JSONValue] = ["id": .integer(1), "method": .string(method)] + if let params { request["params"] = params } + let bytes = try JSONEncoder().encode(JSONValue.object(request)) + do { + switch channel { + case .stable: + _ = try JSONDecoder().decode(CodexAppServerProtocol.Stable.ClientRequest.self, from: bytes) + case .experimental: + _ = try JSONDecoder().decode( + CodexAppServerProtocol.Experimental.ClientRequest.self, from: bytes) + } + } catch { + throw CodexToolError.invalidArguments( + "codex.app.params_invalid: Parameters do not match the SDK's \(channel.rawValue) request type for \(method)." + ) + } + } + + var tool: MCP.Tool { + // Native references remain rooted at the tool schema, including recursive definitions. + var native = parameterSchema?.objectValue ?? [:] + let definitions = native.removeValue(forKey: "definitions") + native.removeValue(forKey: "$schema") + var schema: [String: JSONValue] = [ + "type": .string("object"), + "properties": .object(takesParams ? ["params": .object(native)] : [:]), + "additionalProperties": .bool(false), + ] + if parametersRequired { schema["required"] = .array([.string("params")]) } + if let definitions { schema["definitions"] = definitions } + return .init( + name: toolName, description: description, + inputSchema: try! JSONDecoder().decode( + MCP.Value.self, from: JSONEncoder().encode(JSONValue.object(schema))), + annotations: .init( + readOnlyHint: risk == .readOnly, + destructiveHint: risk == .destructive || risk == .fullShell, + idempotentHint: risk == .readOnly, openWorldHint: true), + outputSchema: .object([ + "type": .string("object"), "properties": .object(["result": .object([:])]), + "required": .array([.string("result")]), "additionalProperties": .bool(false), + ]), + _meta: .init(additionalFields: ["io.github.computer-mcp/risk": .string(risk.rawValue)])) + } +} + +/// SDK adoption defines existence; this package classifies effects and native runtime ownership. +enum CodexAppServerMethodCatalog { + private static let loaded = Result { try derive(inventory: .bundled()) } + + static var methods: [CodexAppServerMethod] { (try? loaded.get()) ?? [] } + + static func validate() throws { _ = try loaded.get() } + + static func method(named name: String) -> CodexAppServerMethod? { + methods.first { $0.method == name } + } + + static func derive(inventory: ProtocolInventory) throws -> [CodexAppServerMethod] { + var result: [CodexAppServerMethod] = [] + for channel in ProtocolInventory.Channel.allCases { + let schema = inventory.schema(channel: channel, direction: .clientRequest) + for name in inventory.adoption.adopted[channel.rawValue] ?? [] { + guard let declaration = schema.message(named: name), let risk = risk(for: name) else { + throw SchemaError.invalid("Adopted SDK method lacks projection policy: \(name).") + } + let params = try declaration.parameters.map { try JSONValue.encoded(schema.standalone($0)) } + let takesParams = params != nil && params?.objectValue?["type"] != .string("null") + result.append( + .init( + method: name, + description: + "Native Codex \(channel.rawValue) request \(name). Risk: \(risk.rawValue). Native parameters and response fields are preserved; asynchronous events are available through codex.app.events.read.", + takesParams: takesParams, risk: risk, channel: channel, + parameterSchema: params, + parametersRequired: takesParams && declaration.parametersRequired, + threadParameters: threadParameters(in: params))) + } + } + guard Set(result.map(\.toolName)).count == result.count else { + throw SchemaError.invalid("Native MCP projection names collide.") + } + return result.sorted { $0.method < $1.method } + } + + private static func threadParameters(in schema: JSONValue?) -> [String: Bool] { + guard let root = schema?.objectValue else { return [:] } + let definitions = root["definitions"]?.objectValue ?? [:] + var pending: [JSONValue] = [.object(root)] + var visited = Set() + var fields: [String: Bool] = [:] + while let value = pending.popLast(), let object = value.objectValue { + if let reference = object["$ref"]?.stringValue, + visited.insert(reference).inserted, + let definition = definitions[String(reference.dropFirst("#/definitions/".count))] + { + pending.append(definition) + } + let required = object["required"]?.arrayValue ?? [] + for key in ["threadId", "beforeThreadId"] where object["properties"]?.objectValue?[key] != nil + { + fields[key] = required.contains(.string(key)) + } + for key in ["anyOf", "oneOf", "allOf"] { pending += object[key]?.arrayValue ?? [] } + } + return fields + } + + static func risk(for method: String) -> CodexOperationRisk? { + switch method { + case "account/rateLimits/read", "account/read", "account/usage/read", + "account/workspaceMessages/read", "app/installed", "app/list", "app/read", "config/read", + "configRequirements/read", "experimentalFeature/list", "externalAgentConfig/detect", + "externalAgentConfig/import/readHistories", "fs/getMetadata", "fs/readDirectory", + "fs/readFile", "hooks/list", "mcpServer/resource/read", "mcpServerStatus/list", "model/list", + "modelProvider/capabilities/read", "permissionProfile/list", "plugin/installed", + "plugin/list", "plugin/read", "plugin/share/list", "plugin/skill/read", "skills/list", + "thread/goal/get", "thread/items/list", "thread/list", "thread/loaded/list", "thread/read", + "thread/turns/list", "threadSection/list", "windowsSandbox/readiness", + "collaborationMode/list", "environment/info", "environment/status", "plugin/search", + "project/list", "project/read", "remoteControl/client/list", "remoteControl/pairing/status", + "remoteControl/status/read", "server/diagnostics", "thread/backgroundTerminals/list", + "thread/queue/list", "thread/realtime/listVoices", "thread/search", + "thread/searchOccurrences", "thread/timeline/list", "mock/experimentalMethod": + return .readOnly + case "thread/goal/clear", "thread/metadata/update", + "thread/name/set", "thread/rollback", "thread/section/move", "thread/unarchive", + "thread/unsubscribe", "threadSection/create", "threadSection/update", + "thread/increment_elicitation", "thread/memoryMode/set", + "thread/queue/delete", "thread/queue/reorder", "command/exec/resize", "process/resizePty": + return .workspaceWrite + case "account/login/cancel", "account/login/start", "account/logout", + "account/rateLimitResetCredit/consume", "account/sendAddCreditsNudgeEmail", + "config/batchWrite", "config/value/write", "experimentalFeature/enablement/set", + "externalAgentConfig/import", "externalAgentConfig/import/recordHistory", "feedback/upload", + "fs/copy", "fs/createDirectory", "fs/unwatch", "fs/watch", "fs/writeFile", "marketplace/add", + "marketplace/upgrade", "mcpServer/oauth/login", "plugin/install", "plugin/share/checkout", + "plugin/share/save", "plugin/share/updateTargets", "skills/config/write", + "skills/extraRoots/set", "windowsSandbox/setupStart", "environment/add", "project/create", + "project/import", "project/move", "project/update", "remoteControl/disable", + "remoteControl/enable", "remoteControl/pairing/start", "mcpServer/event/stream/start", + "mcpServer/event/stream/stop": + return .externalWrite + case "fs/remove", "marketplace/remove", "plugin/share/delete", "plugin/uninstall", + "thread/revert", "threadSection/delete", "memory/reset", "project/delete", + "remoteControl/client/revoke", "thread/backgroundTerminals/clean", + "thread/backgroundTerminals/terminate", "command/exec/terminate", "process/kill": + return .destructive + // Model continuations and native lifecycle hooks can execute user-configured + // commands. Parameter-level sandbox settings do not lower the host floor. + case "config/mcpServer/reload", "review/start", "thread/archive", "thread/compact/start", + "thread/delete", "thread/fork", "thread/goal/set", "thread/inject_items", + "thread/decrement_elicitation", "thread/queue/add", "thread/queue/start", + "thread/queue/update", "thread/realtime/appendAudio", "thread/realtime/appendSpeech", + "thread/realtime/appendText", "thread/realtime/start", "thread/realtime/stop", + "thread/resume", "thread/settings/update", "thread/start", "turn/interrupt", + "turn/settings/update", "turn/start", "turn/steer", "command/exec", + "command/exec/write", "mcpServer/tool/call", "thread/approveGuardianDeniedAction", + "thread/shellCommand", "process/spawn", "process/writeStdin": + return .fullShell + default: return nil + } + } +} diff --git a/Sources/CodexAdapter/CodexAppServerProcessTransport.swift b/Sources/CodexAdapter/CodexAppServerProcessTransport.swift index 18ba425..c3795b1 100644 --- a/Sources/CodexAdapter/CodexAppServerProcessTransport.swift +++ b/Sources/CodexAdapter/CodexAppServerProcessTransport.swift @@ -1,5 +1,4 @@ import CodexAppServerRuntime -import Darwin import Foundation struct CodexAppServerProcessSnapshot: Codable, Equatable, Sendable { @@ -22,6 +21,7 @@ struct CodexAppServerProcessSnapshot: Codable, Equatable, Sendable { let signal: Int32? let terminationEscalated: Bool let lastError: String? + var cleanupConfirmed: Bool? = nil private enum CodingKeys: String, CodingKey { case state @@ -35,6 +35,7 @@ struct CodexAppServerProcessSnapshot: Codable, Equatable, Sendable { case signal case terminationEscalated = "termination_escalated" case lastError = "last_error" + case cleanupConfirmed = "cleanup_confirmed" } var json: JSONValue { @@ -83,7 +84,7 @@ final class ManagedCodexAppServerTransport: CodexAppServerLinePeer, Sendable { terminationGraceMilliseconds: Int = 1_000, killGraceMilliseconds: Int = 2_000, maximumMessageBytes: Int = 16 * 1_024 * 1_024, - ownerProcessID: Int32 = getpid() + ownerProcessID: Int32 = ProcessInfo.processInfo.processIdentifier ) { self.executable = executable self.arguments = arguments @@ -140,6 +141,7 @@ final class ManagedCodexAppServerTransport: CodexAppServerLinePeer, Sendable { exitCode: value.exitCode, signal: value.signal, terminationEscalated: value.terminationEscalated, - lastError: value.lastError) + lastError: value.lastError, + cleanupConfirmed: value.cleanupConfirmed) } } diff --git a/Sources/CodexAdapter/CodexAppServerProvider.swift b/Sources/CodexAdapter/CodexAppServerProvider.swift index e2edf5f..8c9f704 100644 --- a/Sources/CodexAdapter/CodexAppServerProvider.swift +++ b/Sources/CodexAdapter/CodexAppServerProvider.swift @@ -41,10 +41,24 @@ struct CodexAppServerProvider: Sendable { var tools: [MCP.Tool] { Self.definitions.filter { localControlAllowed || $0.name != "codex.app.ownership.reconcile.perform" - } + } + CodexAppServerMethodCatalog.methods.filter { $0.channel == .stable }.map(\.tool) } func call(name: String, arguments: JSONValue?) async throws -> MCP.CallTool.Result { + try CodexAppServerMethodCatalog.validate() + if let method = CodexAppServerMethodCatalog.methods.first(where: { + $0.channel == .stable && $0.toolName == name + }) { + let object = arguments?.objectValue ?? [:] + guard arguments == nil || arguments?.objectValue != nil, + Set(object.keys).isSubset(of: method.takesParams ? ["params"] : []) + else { + throw CodexToolError.invalidArguments( + "Native tool arguments must match its declared schema.") + } + try validateNativeLease(method: method.method, params: object["params"]) + return try Self.result(await appServer.call(method: method.method, params: object["params"])) + } guard let tool = Self.definitions.first(where: { $0.name == name }) else { throw CodexToolError.unknownTool(name) } @@ -146,19 +160,13 @@ struct CodexAppServerProvider: Sendable { case "codex.app.methods.call": let method = try Self.requiredString("method", in: object) let params = object["params"] - if method == "turn/start" { - guard let threadID = params?.objectValue?["threadId"]?.stringValue else { - throw CodexToolError.invalidArguments( - "codex.app.thread_id_required: turn/start requires a non-empty threadId." - ) - } - try CodexWorktreeLeaseManager.validate( - database: database, - workspaceID: owner?.workspaceID, - leaseID: nil, - threadID: threadID - ) + guard let descriptor = CodexAppServerMethodCatalog.method(named: method) else { + throw CodexToolError.invalidArguments("Unknown adopted SDK method: \(method).") + } + guard descriptor.channel != .experimental || object["experimental"] == .bool(true) else { + throw CodexToolError.invalidArguments("Experimental methods require experimental=true.") } + try validateNativeLease(method: method, params: params) result = try await tryAppServer().call( method: method, params: params @@ -188,6 +196,12 @@ struct CodexAppServerProvider: Sendable { method: "thread/read", params: try Self.threadReadParams(in: object) ) + case "codex.app.thread.turns.list", "codex.app.thread.items.list": + let items = name == "codex.app.thread.items.list" + result = try await tryAppServer().call( + method: items ? "thread/items/list" : "thread/turns/list", + params: try Self.threadHistoryParams(in: object, items: items) + ) case "codex.app.thread.recent": guard let recentThreadReader else { throw CodexToolError.disabled( @@ -574,6 +588,10 @@ struct CodexAppServerProvider: Sendable { default: throw CodexToolError.unknownTool(name) } + return try Self.result(result) + } + + private static func result(_ result: JSONValue) throws -> MCP.CallTool.Result { let text = String(decoding: try JSONEncoder().encode(result), as: UTF8.self) let structured = try JSONDecoder().decode( MCP.Value.self, @@ -583,6 +601,19 @@ struct CodexAppServerProvider: Sendable { structuredContent: structured, isError: false) } + private func validateNativeLease(method: String, params: JSONValue?) throws { + guard + [ + "turn/start", "thread/shellCommand", "review/start", "thread/queue/start", + "thread/revert", "thread/approveGuardianDeniedAction", + ].contains(method), + let threadID = params?.objectValue?["threadId"]?.stringValue + else { return } + try CodexWorktreeLeaseManager.validate( + database: database, workspaceID: owner?.workspaceID, + leaseID: nil, threadID: threadID) + } + func shutdown() async { await appServer.shutdown() } private func tryAppServer() throws -> any CodexAppServerRuntimeProtocol { appServer } @@ -642,6 +673,8 @@ struct CodexAppServerProvider: Sendable { "description": .string(method.description), "takes_params": .bool(method.takesParams), "risk": .string(method.risk.rawValue), + "channel": .string(method.channel.rawValue), + "tool": method.channel == .stable ? .string(method.toolName) : .null, ]) } ) @@ -651,7 +684,7 @@ struct CodexAppServerProvider: Sendable { private static func appMethodDescription(method: String) throws -> JSONValue { guard let descriptor = CodexAppServerMethodCatalog.method(named: method) else { throw CodexToolError.invalidArguments( - "codex.app.method_not_allowed: App Server method '\(method)' is not in the reviewed allowlist." + "codex.app.method_not_allowed: App Server method '\(method)' is not adopted by the SDK." ) } return .object([ @@ -659,6 +692,10 @@ struct CodexAppServerProvider: Sendable { "description": .string(descriptor.description), "takes_params": .bool(descriptor.takesParams), "risk": .string(descriptor.risk.rawValue), + "channel": .string(descriptor.channel.rawValue), + "params_schema": descriptor.parameterSchema ?? .null, + "requires_params": .bool(descriptor.parametersRequired), + "native_tool": descriptor.channel == .stable ? .string(descriptor.toolName) : .null, "call_context": .object([ "tool": .string("codex.app.methods.call"), "method": .string(descriptor.method), @@ -723,10 +760,11 @@ struct CodexAppServerProvider: Sendable { private static func threadResumeParams(in object: [String: JSONValue]) throws -> JSONValue { try validateKeys( in: object, - allowed: ["thread_id", "model", "personality", "service_tier"] + allowed: ["thread_id", "model", "personality", "service_tier", "include_turns"] ) var params: [String: JSONValue] = [ - "threadId": .string(try requiredIdentifier("thread_id", in: object)) + "threadId": .string(try requiredIdentifier("thread_id", in: object)), + "excludeTurns": .bool(!(try optionalBool("include_turns", in: object) ?? false)), ] try copyOptionalString("model", to: "model", from: object, into: ¶ms) try copyOptionalString("personality", to: "personality", from: object, into: ¶ms) @@ -781,17 +819,42 @@ struct CodexAppServerProvider: Sendable { try validateKeys(in: object, allowed: ["thread_id", "include_turns"]) return .object([ "threadId": .string(try requiredIdentifier("thread_id", in: object)), - "includeTurns": .bool(try optionalBool("include_turns", in: object) ?? true), + "includeTurns": .bool(try optionalBool("include_turns", in: object) ?? false), ]) } + private static func threadHistoryParams( + in object: [String: JSONValue], items: Bool + ) throws -> JSONValue { + let method = items ? "thread/items/list" : "thread/turns/list" + var params: [String: JSONValue] = [ + "threadId": .string(try requiredIdentifier("thread_id", in: object)), + "limit": .integer( + Int64(try boundedInt("limit", in: object, default: items ? 50 : 20, range: 1...100))), + "sortDirection": .string(try optionalString("sort_direction", in: object) ?? "desc"), + ] + try copyOptionalString("cursor", to: "cursor", from: object, into: ¶ms) + if items { + try copyOptionalString("turn_id", to: "turnId", from: object, into: ¶ms) + } else { + params["itemsView"] = .string(try optionalString("items_view", in: object) ?? "notLoaded") + } + let value = JSONValue.object(params) + guard let descriptor = CodexAppServerMethodCatalog.method(named: method) else { + throw CodexToolError.disabled("The SDK does not adopt \(method).") + } + try descriptor.validate(params: value) + return value + } + private static func threadForkParams(in object: [String: JSONValue]) throws -> JSONValue { try validateKeys( in: object, - allowed: ["thread_id", "model", "ephemeral", "service_tier"] + allowed: ["thread_id", "model", "ephemeral", "service_tier", "include_turns"] ) var params: [String: JSONValue] = [ - "threadId": .string(try requiredIdentifier("thread_id", in: object)) + "threadId": .string(try requiredIdentifier("thread_id", in: object)), + "excludeTurns": .bool(!(try optionalBool("include_turns", in: object) ?? false)), ] try copyOptionalString("model", to: "model", from: object, into: ¶ms) try copyOptionalBool("ephemeral", to: "ephemeral", from: object, into: ¶ms) @@ -1058,7 +1121,7 @@ struct CodexAppServerProvider: Sendable { "codex.argument_invalid: '\(key)' must be an integer between \(range.lowerBound) and \(range.upperBound)." ) } - result[targetKey] = .number(Double(value)) + result[targetKey] = .integer(Int64(value)) } private static func copyOptionalObject( @@ -1239,7 +1302,8 @@ struct CodexAppServerProvider: Sendable { tool( "codex.diagnostics.snapshot", "Read one redacted, workspace-scoped operational snapshot that correlates Codex runtimes, process and connection ownership, approvals, acceptance runs, worktree leases, cleanup state, and recent tool or Git audit receipts.", - objectSchema(properties: ["limit": integerSchema(minimum: 1, maximum: 1_000)]) + objectSchema(properties: ["limit": integerSchema(minimum: 1, maximum: 1_000)]), + hostAction: "diagnostics.snapshot" ), tool( "codex.worktree.managed.list", @@ -1269,7 +1333,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["agent_id", "parent_lease_id", "branch"] ), - write: true + risk: .workspaceWrite ), tool( "codex.worktree.provision.perform", @@ -1282,7 +1346,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["plan_id", "expected_revision", "confirm_provision"] ), - write: true + risk: .workspaceWrite, hostAction: "workspaces.provision" ), tool( "codex.worktree.remove.plan", @@ -1291,7 +1355,7 @@ struct CodexAppServerProvider: Sendable { properties: ["managed_worktree_id": stringSchema()], required: ["managed_worktree_id"] ), - write: true + risk: .workspaceWrite ), tool( "codex.worktree.remove.perform", @@ -1304,7 +1368,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["managed_worktree_id", "expected_revision", "confirm_remove"] ), - write: true + risk: .destructive, hostAction: "workspaces.remove" ), tool( "codex.app.status", "Read the persistent Codex App Server connection status.", emptySchema), @@ -1330,7 +1394,7 @@ struct CodexAppServerProvider: Sendable { properties: ["confirm_cleanup": booleanSchema()], required: ["confirm_cleanup"] ), - write: true + risk: .workspaceWrite ), tool( "codex.app.ownership.reconcile.preview", @@ -1347,7 +1411,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["expected_plan_digest", "confirm_reconciliation"] ), - write: true + risk: .workspaceWrite ), tool( "codex.app.runtimes.inspect", @@ -1358,11 +1422,11 @@ struct CodexAppServerProvider: Sendable { "codex.app.runtimes.stop", "Stop and reap one specific Computer MCP-owned runtime. Other Codex applications and user-owned processes are never targeted.", objectSchema(properties: ["runtime_id": stringSchema()], required: ["runtime_id"]), - write: true + risk: .destructive ), tool( "codex.app.methods.list", - "List reviewed Codex App Server RPC methods. Authentication, configuration mutation, marketplace mutation, raw shell, filesystem bypass, and remote pairing methods are never included.", + "List SDK-adopted Codex App Server methods, their stability channel, typed tool and operation risk.", emptySchema ), tool( @@ -1376,6 +1440,7 @@ struct CodexAppServerProvider: Sendable { objectSchema( properties: [ "method": stringSchema(), + "experimental": booleanSchema(), "params": .object([ "type": .string("object"), "additionalProperties": .bool(true), @@ -1383,7 +1448,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["method"] ), - write: true + risk: .fullShell ), tool( "codex.app.thread.start", "Start a Codex thread in the bound workspace.", @@ -1395,7 +1460,7 @@ struct CodexAppServerProvider: Sendable { "service_tier": stringSchema(), ] ), - write: true), + risk: .fullShell), tool( "codex.app.thread.list", "List Codex threads restricted to the bound workspace.", @@ -1413,17 +1478,18 @@ struct CodexAppServerProvider: Sendable { ), tool( "codex.app.thread.reclaim", - "Explicitly resume a persisted thread under the current Computer MCP runtime after workspace validation. A writer conflict is reported without terminating external Codex applications.", + "Explicitly resume a persisted thread under the current Computer MCP runtime after workspace validation. Returns metadata and live resume state by default; include_turns=true requests full history. A writer conflict is reported without terminating external Codex applications.", objectSchema( properties: [ "thread_id": stringSchema(), "model": stringSchema(), "personality": stringSchema(), "service_tier": stringSchema(), + "include_turns": booleanSchema(), ], required: ["thread_id"] ), - write: true + risk: .fullShell ), tool( "codex.app.thread.loaded.list", @@ -1436,7 +1502,8 @@ struct CodexAppServerProvider: Sendable { ) ), tool( - "codex.app.thread.read", "Read one Codex thread after verifying its workspace.", + "codex.app.thread.read", + "Read thread metadata by default. Use thread.turns.list and thread.items.list for bounded history. Explicit include_turns=true requests full history and remains subject to transport, output and timeout limits.", objectSchema( properties: [ "thread_id": stringSchema(), @@ -1445,6 +1512,25 @@ struct CodexAppServerProvider: Sendable { required: ["thread_id"] ) ), + tool( + "codex.app.thread.turns.list", + "Read a page of turns, newest first by default, without loading items. Default limit 20, maximum 100. Pass the returned nextCursor unchanged as cursor with the same thread and sort direction; items_view can request native summary or full detail.", + objectSchema( + properties: [ + "thread_id": stringSchema(), "cursor": stringSchema(), + "limit": integerSchema(minimum: 1, maximum: 100), + "sort_direction": stringSchema(), "items_view": stringSchema(), + ], required: ["thread_id"]) + ), + tool( + "codex.app.thread.items.list", + "Read a page of thread items, newest first by default. Default limit 50, maximum 100. Optional turn_id selects one turn. Pass nextCursor unchanged as cursor with the same thread, turn and sort direction. Oversized pages fail explicitly; retry the same cursor with a smaller limit.", + objectSchema( + properties: [ + "thread_id": stringSchema(), "turn_id": stringSchema(), "cursor": stringSchema(), + "limit": integerSchema(minimum: 1, maximum: 100), "sort_direction": stringSchema(), + ], required: ["thread_id"]) + ), tool( "codex.app.thread.recent", "Read bounded metadata, Goal status, active/recent turns, messages, items, pending lifecycle state, and compact progress from a persisted thread without loading full history.", @@ -1463,17 +1549,19 @@ struct CodexAppServerProvider: Sendable { ) ), tool( - "codex.app.thread.fork", "Fork a Codex thread in the bound workspace.", + "codex.app.thread.fork", + "Fork a Codex thread in the bound workspace. Returns metadata and live fork state by default; include_turns=true requests full history.", objectSchema( properties: [ "thread_id": stringSchema(), "model": stringSchema(), "ephemeral": booleanSchema(), "service_tier": stringSchema(), + "include_turns": booleanSchema(), ], required: ["thread_id"] ), - write: true), + risk: .fullShell), tool( "codex.app.thread.release", "Release a thread from every matching Computer-MCP-owned runtime and verify that another official Codex client can claim it immediately. Active turns are interrupted only when explicitly requested; force mode can stop only exact Computer-MCP-owned runtimes.", @@ -1491,7 +1579,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["thread_id"] ), - write: true + risk: .fullShell ), tool( "codex.app.handoff.diagnose", @@ -1527,22 +1615,23 @@ struct CodexAppServerProvider: Sendable { ], required: ["thread_id"] ), - write: true + risk: .fullShell ), tool( "codex.app.goal.clear", "Clear the official persisted Codex Goal for a verified workspace thread.", objectSchema(properties: ["thread_id": stringSchema()], required: ["thread_id"]), - write: true + risk: .workspaceWrite ), tool( "codex.app.runtime.stop", "Release all thread subscriptions and stop the current Computer MCP-owned App Server runtime.", emptySchema, - write: true + risk: .destructive ), tool( - "codex.app.turn.start", "Start a Codex turn with gateway-owned sandbox and approval policy.", + "codex.app.turn.start", + "Start a Codex turn using native sandbox and approval settings under host authorization.", objectSchema( properties: [ "thread_id": stringSchema(), @@ -1556,7 +1645,7 @@ struct CodexAppServerProvider: Sendable { "worktree_lease_id": stringSchema(), ], required: ["thread_id", "prompt"] - ), write: true), + ), risk: .fullShell), tool( "codex.app.turn.steer", "Steer the currently active Codex turn. The expected turn ID prevents instructions from being applied to a newer turn.", @@ -1569,14 +1658,14 @@ struct CodexAppServerProvider: Sendable { ], required: ["thread_id", "expected_turn_id", "prompt"] ), - write: true + risk: .fullShell ), tool( "codex.app.turn.interrupt", "Interrupt an active Codex turn.", objectSchema( properties: ["thread_id": stringSchema(), "turn_id": stringSchema()], required: ["thread_id", "turn_id"] - ), write: true), + ), risk: .fullShell), tool( "codex.app.review.start", "Start a Codex review for a verified workspace thread.", objectSchema( @@ -1589,7 +1678,7 @@ struct CodexAppServerProvider: Sendable { ]), ], required: ["thread_id", "target"] - ), write: true), + ), risk: .fullShell), tool( "codex.app.models.list", "List models exposed by Codex App Server.", objectSchema( @@ -1631,7 +1720,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["request_id", "response"] ), - write: true + risk: .fullShell ), tool( "codex.app.approvals.list", @@ -1667,7 +1756,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["approval_id", "response"] ), - write: true + risk: .fullShell ), tool( "codex.run.create", @@ -1689,7 +1778,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["objective", "accepted_scope", "acceptance_criteria"] ), - write: true + risk: .workspaceWrite ), tool( "codex.run.list", @@ -1736,7 +1825,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["run_id", "expected_revision", "event", "summary"] ), - write: true + risk: .workspaceWrite ), tool( "codex.run.evaluate", @@ -1748,7 +1837,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["run_id", "expected_revision"] ), - write: true + risk: .workspaceWrite ), tool( "codex.run.accept", @@ -1761,7 +1850,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["run_id", "expected_revision", "worktree_clean"] ), - write: true + risk: .workspaceWrite ), tool( "codex.run.transition", @@ -1778,7 +1867,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["run_id", "expected_revision", "action"] ), - write: true + risk: .workspaceWrite ), tool( "codex.run.reconcile", @@ -1797,7 +1886,7 @@ struct CodexAppServerProvider: Sendable { "criterion_id", ] ), - write: true + risk: .workspaceWrite ), tool( "codex.worktree.leases.acquire", @@ -1817,7 +1906,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["agent_id"] ), - write: true + risk: .workspaceWrite ), tool( "codex.worktree.leases.list", @@ -1840,7 +1929,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["lease_id", "expected_revision"] ), - write: true + risk: .workspaceWrite ), tool( "codex.worktree.leases.release", @@ -1853,7 +1942,7 @@ struct CodexAppServerProvider: Sendable { ], required: ["lease_id", "expected_revision", "reason"] ), - write: true + risk: .workspaceWrite ), tool( "codex.worktree.leases.cleanup.preview", @@ -1867,26 +1956,31 @@ struct CodexAppServerProvider: Sendable { properties: ["confirm_cleanup": booleanSchema()], required: ["confirm_cleanup"] ), - write: true + risk: .workspaceWrite ), ] private static func tool( - _ name: String, _ description: String, _ inputSchema: JSONValue, write: Bool = false + _ name: String, _ description: String, _ inputSchema: JSONValue, + risk: CodexOperationRisk = .readOnly, hostAction: String? = nil ) -> MCP.Tool { let title = name.split(whereSeparator: { $0 == "." || $0 == "_" || $0 == "-" }) .map { String($0.prefix(1)).uppercased() + $0.dropFirst() }.joined(separator: " ") let input = try! JSONDecoder().decode( MCP.Value.self, from: JSONEncoder().encode(inputSchema)) + var metadata: [String: MCP.Value] = ["io.github.computer-mcp/risk": .string(risk.rawValue)] + if let hostAction { metadata["io.github.computer-mcp/host-action"] = .string(hostAction) } return .init( name: name, title: title, description: description, inputSchema: input, annotations: .init( - readOnlyHint: !write, destructiveHint: name == "codex.worktree.remove.perform", - idempotentHint: !write, openWorldHint: write), + readOnlyHint: risk == .readOnly, + destructiveHint: risk == .destructive || risk == .fullShell, + idempotentHint: risk == .readOnly, openWorldHint: risk != .readOnly), outputSchema: .object([ "type": .string("object"), "properties": .object(["result": .object([:])]), "required": .array([.string("result")]), "additionalProperties": .bool(false), - ])) + ]), + _meta: .init(additionalFields: metadata)) } private static func objectSchema( @@ -1946,10 +2040,10 @@ struct CodexAppServerProvider: Sendable { private static func integerSchema(minimum: Int, maximum: Int? = nil) -> JSONValue { var schema: [String: JSONValue] = [ "type": .string("integer"), - "minimum": .number(Double(minimum)), + "minimum": .integer(Int64(minimum)), ] if let maximum { - schema["maximum"] = .number(Double(maximum)) + schema["maximum"] = .integer(Int64(maximum)) } return .object(schema) } diff --git a/Sources/CodexAdapter/CodexAppServerRuntime.swift b/Sources/CodexAdapter/CodexAppServerRuntime.swift index 3768e0e..65058de 100644 --- a/Sources/CodexAdapter/CodexAppServerRuntime.swift +++ b/Sources/CodexAdapter/CodexAppServerRuntime.swift @@ -1,14 +1,12 @@ import CodexAppServerClient import CodexAppServerProtocol -import CryptoKit import Foundation -struct CodexAppServerMethod: Equatable, Sendable { - let method: String - let description: String - let takesParams: Bool - let risk: CodexOperationRisk -} +#if canImport(CryptoKit) + import CryptoKit +#else + import Crypto +#endif struct CodexRuntimeOwner: Codable, Equatable, Sendable { let workspaceID: String? @@ -122,213 +120,6 @@ final class CodexRuntimeDirectory: @unchecked Sendable { } } -enum CodexAppServerMethodCatalog { - static let methods: [CodexAppServerMethod] = [ - .init( - method: "config/read", description: "Read effective native Codex configuration.", - takesParams: true, risk: .readOnly), - .init( - method: "configRequirements/read", - description: "Read managed native Codex configuration requirements.", takesParams: false, - risk: .readOnly), - .init( - method: "mcpServerStatus/list", description: "Read native MCP connection state.", - takesParams: true, risk: .readOnly), - .init( - method: "thread/turns/list", description: "Read a page of persisted thread turns.", - takesParams: true, risk: .readOnly), - .init( - method: "thread/items/list", description: "Read a page of persisted turn items.", - takesParams: true, risk: .readOnly), - - .init( - method: "account/rateLimits/read", - description: "Read the current Codex account rate-limit snapshot.", - takesParams: false, - risk: .readOnly - ), - .init( - method: "account/read", - description: "Read non-secret Codex account metadata.", - takesParams: true, - risk: .readOnly - ), - .init( - method: "account/usage/read", - description: "Read the current Codex account token-usage summary.", - takesParams: false, - risk: .readOnly - ), - .init( - method: "app/list", - description: "List Codex apps available to the installed Codex runtime.", - takesParams: true, - risk: .readOnly - ), - .init( - method: "experimentalFeature/list", - description: "List experimental Codex feature metadata.", - takesParams: true, - risk: .readOnly - ), - .init( - method: "model/list", - description: "List models exposed by the installed Codex runtime.", - takesParams: true, - risk: .readOnly - ), - .init( - method: "plugin/list", - description: "List installed Codex plugins without mutating them.", - takesParams: true, - risk: .readOnly - ), - .init( - method: "plugin/read", - description: "Read metadata for one installed Codex plugin.", - takesParams: true, - risk: .readOnly - ), - .init( - method: "skills/list", - description: "List Skills discovered by Codex.", - takesParams: true, - risk: .readOnly - ), - .init( - method: "thread/list", - description: "List Codex threads.", - takesParams: true, - risk: .readOnly - ), - .init( - method: "thread/loaded/list", - description: "List thread IDs currently loaded by this App Server runtime.", - takesParams: true, - risk: .readOnly - ), - .init( - method: "thread/read", - description: "Read one Codex thread and its persisted turns.", - takesParams: true, - risk: .readOnly - ), - .init( - method: "thread/start", - description: - "Start a Codex thread using native configuration and an optional initial directory.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/resume", - description: "Resume a Codex thread with native configuration.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/fork", - description: "Fork an existing Codex thread.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/goal/get", - description: "Read the official persisted Codex Goal for one thread.", - takesParams: true, - risk: .readOnly - ), - .init( - method: "thread/goal/set", - description: "Create or update the official persisted Codex Goal for one thread.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/goal/clear", - description: "Clear the official persisted Codex Goal for one thread.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/compact/start", - description: "Start compaction for one Codex thread.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/inject_items", - description: "Inject protocol items into one Codex thread.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/metadata/update", - description: "Update reviewed metadata for one Codex thread.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/name/set", - description: "Set the display name of one Codex thread.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/rollback", - description: "Roll a Codex thread back to a prior turn.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/archive", - description: "Archive one Codex thread.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/unarchive", - description: "Unarchive one Codex thread.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "thread/unsubscribe", - description: "Unsubscribe the App Server connection from one thread.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "turn/start", - description: "Start a turn using the native Codex execution policy.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "turn/steer", - description: "Steer an active Codex turn.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "turn/interrupt", - description: "Interrupt an active Codex turn.", - takesParams: true, - risk: .workspaceWrite - ), - .init( - method: "review/start", - description: "Start a Codex review for a thread.", - takesParams: true, - risk: .workspaceWrite - ), - ] - - static func method(named name: String) -> CodexAppServerMethod? { - methods.first { $0.method == name } - } -} - protocol CodexAppServerRuntimeProtocol: Sendable { func status() async -> JSONValue func call(method: String, params: JSONValue?) async throws -> JSONValue @@ -338,10 +129,14 @@ protocol CodexAppServerRuntimeProtocol: Sendable { func approvals(state: String?, limit: Int) async throws -> JSONValue func approval(id: String) async throws -> JSONValue func respondToApproval(id: String, response: JSONValue) async throws -> JSONValue + func workResources() async throws -> [CodexWorkResource] func shutdown() async } extension CodexAppServerRuntimeProtocol { + func workResources() async throws -> [CodexWorkResource] { + throw CodexToolError.disabled("codex.app.work_unavailable: Runtime ownership is unavailable.") + } func approvals(state: String?, limit: Int) async throws -> JSONValue { .object(["approvals": .array([])]) } @@ -433,6 +228,7 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { private typealias Stable = CodexAppServerProtocol.Stable private struct PendingUserInputRequest: Sendable { let handle: CodexAppServerRawServerRequest + let connection: CodexAppServerConnection let payload: JSONValue let threadID: String? var kind: String { @@ -443,15 +239,54 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { private struct ConnectionStartup: Sendable { let id: UUID + let work: CodexWorkBinding let transport: ManagedCodexAppServerTransport let task: Task } private struct RequestGenerationRetirement: Sendable { let id: UUID + let work: CodexWorkBinding + let generation: Int + let transport: ManagedCodexAppServerTransport? let task: Task } + private struct TurnWorkKey: Hashable, Sendable { + let threadID: String + let turnID: String + } + + private struct ThreadCleanupWork: Sendable { + let threadID: String + let generation: Int + let binding: CodexWorkBinding + var requiresProcessExit: Bool + } + + private struct ServerRequestWorkKey: Hashable, Sendable { + let connection: ObjectIdentifier + let requestID: String + } + + private struct ServerRequestWork: Sendable { + let binding: CodexWorkBinding + let connection: CodexAppServerConnection + let generation: Int + let threadID: String? + let turnID: String? + var approvalID: String? + var state: CodexWorkResource.State = .active + var handlerActive = true + var nativeSettled = false + var task: Task? + } + + private struct NativeReply: Sendable { + let connection: CodexAppServerConnection + let value: JSONValue + } + struct RequestTimeoutError: Error, LocalizedError, Sendable { let seconds: Int @@ -476,7 +311,6 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { private var requestGenerationRetirement: RequestGenerationRetirement? private var lastProcessSnapshot: CodexAppServerProcessSnapshot? private var notificationTask: Task? - private var requestTask: Task? private var pendingUserInputRequests: [String: PendingUserInputRequest] = [:] private var approvalRecords: [String: CodexApprovalRecord] = [:] private var pendingApprovalHandles: [String: PendingApprovalHandle] = [:] @@ -488,11 +322,32 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { private var threadStates: [String: JSONValue] = [:] private var activeTurnIDs: [String: String] = [:] private var turnStartInFlight: Set = [] + private var detachedReviewInFlight = false + private var completedTurnsDuringStart: Set = [] + private var closedThreadsDuringStart: Set = [] private var threadStartInFlight = false + private var threadResumeInFlight: Set = [] private var handoffPreparations: [String: UUID] = [:] private var connectionState = "idle" private var connectionID: String? private var connectionGeneration = 0 + private var nativeResources = CodexNativeResources() + private var asyncNativeWork = CodexAppAsyncWork() + private var remoteControlWork = CodexRemoteControlWork() + private var queuedWork = CodexQueuedWork() + private var threadWork: [String: CodexWorkBinding] = [:] + private var threadCleanupWork: [String: ThreadCleanupWork] = [:] + private var turnWork: [TurnWorkKey: CodexWorkBinding] = [:] + private var goalWork: [String: CodexWorkBinding] = [:] + private var goalObservationToken = UUID() + private var goalMutationInFlight: Set = [] + private var pendingGoalOrigins: [String: CodexWorkOrigin] = [:] + private var goalNotificationsDuringMutation: Set = [] + private var uncertainGoalWork: Set = [] + private var pendingCallWork: [String: CodexWorkBinding] = [:] + private var serverRequestWork: [ServerRequestWorkKey: ServerRequestWork] = [:] + private var cleanupWork: CodexWorkBinding? + private var unconfirmedTransport: (generation: Int, transport: ManagedCodexAppServerTransport)? private var shutdownReason: String? private var isShutdown = false private var lastError: String? @@ -544,7 +399,120 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { database: database, runtimeID: record.runtimeID)) == false } + func workResources() async throws -> [CodexWorkResource] { + await recheckProcessCleanup() + let state: CodexWorkResource.State = + requestGenerationRetirement != nil || unconfirmedTransport != nil ? .uncertain : .active + var rows = + try nativeResources.workResources() + asyncNativeWork.workResources() + + remoteControlWork.workResources() + + queuedWork.workResources() + for id in loadedThreadIDs.union(subscribedThreadIDs) { + let binding = threadBinding(id) + if threadCleanupWork[binding.id] == nil { + rows.append( + try binding.resource( + "codex.app.thread", state: state, handles: ["thread_id": .string(id)])) + } + } + rows += try threadCleanupWork.values.map { + try $0.binding.resource( + "codex.app.thread", state: .uncertain, handles: ["thread_id": .string($0.threadID)]) + } + for (threadID, turnID) in activeTurnIDs { + rows.append( + try turnBinding(threadID: threadID, turnID: turnID).resource( + "codex.app.turn", state: state, + handles: ["thread_id": .string(threadID), "turn_id": .string(turnID)]) + ) + } + rows += try goalWork.map { threadID, work in + try work.resource( + "codex.app.goal", state: uncertainGoalWork.contains(threadID) ? .uncertain : state, + handles: ["thread_id": .string(threadID)]) + } + rows += try pendingCallWork.values.map { try $0.resource("codex.app.call") } + rows += try serverRequestWork.map { key, work in + var handles: [String: JSONValue] = ["request_id": .string(key.requestID)] + if let id = work.threadID { handles["thread_id"] = .string(id) } + if let id = work.turnID { handles["turn_id"] = .string(id) } + if let id = work.approvalID { handles["approval_id"] = .string(id) } + return try work.binding.resource( + "codex.app.server-request", state: state == .uncertain ? state : work.state, + handles: handles) + } + if let startup = connectionStartup { + rows.append(try startup.work.resource("codex.app.startup")) + } + if let retirement = requestGenerationRetirement { + rows.append(try retirement.work.resource("codex.app.cleanup", state: .uncertain)) + } else if let cleanupWork, unconfirmedTransport != nil { + rows.append(try cleanupWork.resource("codex.app.cleanup", state: .uncertain)) + } + return try rows.map { try $0.addingHandles(["runtime_id": .string(runtimeID)]) } + .sorted { ($0.kind, $0.id) < ($1.kind, $1.id) } + } + + private func threadBinding(_ id: String) -> CodexWorkBinding { + if let binding = threadWork[id] { return binding } + if let cleanup = threadCleanupWork.values.first(where: { $0.threadID == id }) { + return cleanup.binding + } + let binding = CodexWorkBinding() + threadWork[id] = binding + return binding + } + + private func turnBinding( + threadID: String, turnID: String, origin: CodexWorkOrigin? = nil + ) -> CodexWorkBinding { + let key = TurnWorkKey(threadID: threadID, turnID: turnID) + if let binding = turnWork[key] { return binding } + let binding = CodexWorkBinding(origin: origin ?? .init()) + turnWork[key] = binding + return binding + } + + private func requestOrigin(threadID: String?, turnID: String?) -> CodexWorkOrigin { + guard let threadID else { return .init() } + if let turnID { + let parentOrigin = + turnStartInFlight.contains(threadID) || queuedWork.hasPending(threadID: threadID) + ? nil + : (goalWork[threadID]?.origin ?? pendingGoalOrigins[threadID] + ?? threadBinding(threadID).origin) + return turnBinding(threadID: threadID, turnID: turnID, origin: parentOrigin).origin + } + return threadBinding(threadID).origin + } + + private func pruneWorkBindings() { + let threadCreationPending = + threadStartInFlight || detachedReviewInFlight || !threadResumeInFlight.isEmpty + if !threadCreationPending { closedThreadsDuringStart.removeAll() } + let liveThreads = loadedThreadIDs.union(subscribedThreadIDs) + let pendingThreads = Set(serverRequestWork.values.compactMap(\.threadID)) + threadWork = threadWork.filter { + threadCreationPending || liveThreads.contains($0.key) || pendingThreads.contains($0.key) + } + let pendingTurns = Set( + serverRequestWork.values.compactMap { request -> TurnWorkKey? in + guard let threadID = request.threadID, let turnID = request.turnID else { return nil } + return .init(threadID: threadID, turnID: turnID) + }) + completedTurnsDuringStart = completedTurnsDuringStart.filter { + detachedReviewInFlight || turnStartInFlight.contains($0.threadID) + } + turnWork = turnWork.filter { key, _ in + activeTurnIDs[key.threadID] == key.turnID + || detachedReviewInFlight || turnStartInFlight.contains(key.threadID) + || pendingTurns.contains(key) + || threadCleanupWork.values.contains { $0.threadID == key.threadID } + } + } + func status() async -> JSONValue { + await recheckProcessCleanup() let processSnapshot = await (processTransport ?? connectionStartup?.transport)?.snapshot() ?? lastProcessSnapshot @@ -573,20 +541,22 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { "created_at": (try? JSONValue.encoded(createdAt)) ?? .null, "owner": owner.flatMap { try? JSONValue.encoded($0) } ?? .null, "state": .string(connectionState), - "runtime_state": .string(isShutdown ? "stopped" : "running"), + "runtime_state": .string( + unconfirmedTransport != nil ? "cleanup-pending" : (isShutdown ? "stopped" : "running")), "connection_state": .string(connectionState), "process_state": processSnapshot.map { .string($0.state.rawValue) } ?? .string("absent"), "current_request_state": .string(currentRequestState), - "current_request_count": .number(Double(activeRequestCount)), + "current_request_count": .integer(Int64(activeRequestCount)), "last_request_failure": lastRequestFailure?.json ?? .null, "connection_id": connectionID.map(JSONValue.string) ?? .null, - "connection_generation": .number(Double(connectionGeneration)), + "connection_generation": .integer(Int64(connectionGeneration)), + "native_resource_count": .integer(Int64(nativeResources.count)), "experimental_api": .bool(configuration.experimentalAPI), "workspace": .string(workspaceURL.path), "last_error": lastError.map(JSONValue.string) ?? .null, "shutdown_reason": shutdownReason.map(JSONValue.string) ?? .null, - "pending_user_input_requests": .number(Double(pendingUserInputRequests.count)), - "pending_approvals": .number(Double(pendingApprovals.count)), + "pending_user_input_requests": .integer(Int64(pendingUserInputRequests.count)), + "pending_approvals": .integer(Int64(pendingApprovals.count)), "pending_approval_ids": .array(pendingApprovalIDs), "threads": .array(threads), "process": processSnapshot?.json ?? .null, @@ -602,6 +572,10 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { $0.threadID == threadID && $0.state == .pending } || pendingUserInputRequests.values.contains { $0.threadID == threadID } + || serverRequestWork.values.contains { $0.threadID == threadID } + || threadCleanupWork.values.contains { $0.threadID == threadID } + || asyncNativeWork.hasWork(threadID: threadID) + || queuedWork.hasPending(threadID: threadID) } func prepareForHandoff( @@ -749,9 +723,14 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { let officialLoaded = Set( response.objectValue?["data"]?.arrayValue?.compactMap(\.stringValue) ?? [] ) + let removed = loadedThreadIDs.union(subscribedThreadIDs).subtracting(officialLoaded) + for id in removed { retainThreadCleanup(threadID: id) } loadedThreadIDs = officialLoaded subscribedThreadIDs.formIntersection(officialLoaded) - loadedState = officialLoaded.contains(threadID) ? "still-loaded" : "not-loaded" + loadedState = + officialLoaded.contains(threadID) + || threadCleanupWork.values.contains { $0.threadID == threadID } + ? "still-loaded" : "not-loaded" } if loadedState == "still-loaded" { @@ -777,6 +756,7 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { subscribedThreadIDs.remove(threadID) activeTurnIDs.removeValue(forKey: threadID) threadStates[threadID] = .string("released") + threadWork.removeValue(forKey: threadID) try persistThreadOwnership(threadID: threadID, state: .released) if isEligibleForIdleReaping(ignoring: threadID) { @@ -816,26 +796,96 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { let otherHandoffs = handoffPreparations.keys.contains { $0 != threadID } return otherLoaded.isEmpty && otherSubscribed.isEmpty && !otherActive && !otherPendingApprovals && !otherPendingInput && !otherHandoffs + && nativeResources.count == 0 && asyncNativeWork.isEmpty && pendingCallWork.isEmpty + && remoteControlWork.isEmpty + && queuedWork.isEmpty + && serverRequestWork.isEmpty && !threadStartInFlight && !detachedReviewInFlight + && turnStartInFlight.isEmpty && threadResumeInFlight.isEmpty + && !goalWork.keys.contains { $0 != threadID } && goalMutationInFlight.isEmpty + && !threadCleanupWork.values.contains { $0.threadID != threadID } } func call(method: String, params: JSONValue?) async throws -> JSONValue { + try CodexAppServerMethodCatalog.validate() guard let descriptor = CodexAppServerMethodCatalog.method(named: method) else { throw CodexToolError.disabled( - "codex.app.method_not_allowed: App Server method '\(method)' is not in the reviewed allowlist." + "codex.app.method_not_allowed: App Server method '\(method)' is not adopted by the SDK." ) } + guard descriptor.channel != .experimental || configuration.experimentalAPI else { + throw CodexToolError.disabled( + "Experimental App Server requests are disabled in this runtime.") + } let normalized = try normalize(params: params, for: descriptor) + try descriptor.validate(params: normalized) if let threadID = try Self.workspaceScopedThreadID(method: method, params: normalized) { try threadOwnerIndex?.check(threadID: threadID) if descriptor.risk != .readOnly, method != "thread/fork" { try threadOwnerIndex?.claim(threadID: threadID) } } + if let beforeThreadID = normalized?.objectValue?["beforeThreadId"]?.stringValue, + descriptor.threadParameters["beforeThreadId"] != nil + { + try threadOwnerIndex?.check(threadID: Self.validatedThreadID(beforeThreadID)) + } + let targetThreadID = normalized?.objectValue?["threadId"]?.stringValue + let previouslyOwnedThreads = loadedThreadIDs.union(subscribedThreadIDs) + let resumeThreadID = method == "thread/resume" ? targetThreadID : nil + if let resumeThreadID { + guard !threadCleanupWork.values.contains(where: { $0.threadID == resumeThreadID }) else { + throw CodexToolError.disabled( + "codex.app.thread_cleanup_pending: Previous native thread cleanup is not yet confirmed.") + } + guard threadResumeInFlight.insert(resumeThreadID).inserted else { + throw CodexToolError.disabled( + "codex.app.thread_resume_in_flight: This thread is already being resumed.") + } + } + defer { + if let resumeThreadID { threadResumeInFlight.remove(resumeThreadID) } + pruneWorkBindings() + } + let queriedGoalWorkID = targetThreadID.flatMap { goalWork[$0]?.id } + let queriedGoalObservation = goalObservationToken + let goalMutationThreadID = + ["thread/goal/set", "thread/goal/clear"].contains(method) ? targetThreadID : nil + if let goalMutationThreadID { + guard goalMutationInFlight.insert(goalMutationThreadID).inserted else { + throw CodexToolError.disabled( + "codex.app.goal_request_in_flight: Another Goal mutation is still settling for this thread." + ) + } + } + defer { + if let goalMutationThreadID { + goalMutationInFlight.remove(goalMutationThreadID) + goalNotificationsDuringMutation.remove(goalMutationThreadID) + pendingGoalOrigins.removeValue(forKey: goalMutationThreadID) + } + } + let isDetachedReview = + method == "review/start" && normalized?.objectValue?["delivery"] == .string("detached") + if isDetachedReview { + guard !detachedReviewInFlight else { + throw CodexToolError.disabled( + "codex.app.review_start_in_flight: Another detached review is still being started.") + } + detachedReviewInFlight = true + } + defer { + if isDetachedReview { + detachedReviewInFlight = false + pruneWorkBindings() + } + } let turnStartThreadID = - method == "turn/start" ? normalized?.objectValue?["threadId"]?.stringValue : nil + ["turn/start", "thread/queue/start", "review/start"].contains(method) && !isDetachedReview + ? targetThreadID : nil let turnStartPriorState = turnStartThreadID.flatMap { threadStates[$0] } let turnStartPriorActiveTurnID = turnStartThreadID.flatMap { activeTurnIDs[$0] } - if method == "thread/start", !handoffPreparations.isEmpty { + let createsThread = ["thread/start", "thread/fork"].contains(method) + if createsThread, !handoffPreparations.isEmpty { throw CodexToolError.disabled( "codex.app.handoff_in_progress: A thread release transaction is in progress on this runtime." ) @@ -850,16 +900,16 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { if let turnStartThreadID { guard turnStartInFlight.insert(turnStartThreadID).inserted else { throw CodexToolError.disabled( - "codex.app.turn_start_in_flight: Another turn/start is already being committed for this thread." + "codex.app.turn_start_in_flight: Another turn is already being started for this thread." ) } threadStates[turnStartThreadID] = .string("starting") activeTurnIDs.removeValue(forKey: turnStartThreadID) } - if method == "thread/start" { + if createsThread { guard !threadStartInFlight else { throw CodexToolError.disabled( - "codex.app.thread_start_in_flight: Another thread/start is already being committed by this runtime." + "codex.app.thread_start_in_flight: Another thread is already being started by this runtime." ) } threadStartInFlight = true @@ -868,19 +918,27 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { if let turnStartThreadID { turnStartInFlight.remove(turnStartThreadID) } - if method == "thread/start" { + if createsThread { threadStartInFlight = false } + pruneWorkBindings() } let normalizedRequest = normalized + let creator = CodexWorkInvocation.current + let work = CodexWorkBinding(origin: .init(invocation: creator)) + if method == "thread/goal/set", let targetThreadID { + pendingGoalOrigins[targetThreadID] = goalWork[targetThreadID]?.origin ?? work.origin + } + pendingCallWork[work.id] = work activeRequestCount += 1 currentRequestState = "running" defer { + pendingCallWork.removeValue(forKey: work.id) activeRequestCount = max(0, activeRequestCount - 1) currentRequestState = activeRequestCount == 0 ? "idle" : "running" persistRuntimeLease(state: connectionState, reason: nil) } - let response: JSONValue + let reply: NativeReply do { let totalTimeoutSeconds = Self.requestTimeoutSeconds( method: method, @@ -892,21 +950,21 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { risk: descriptor.risk, method: method ) - response = try await Self.boundedRequest( + reply = try await Self.boundedRequest( timeoutSeconds: totalTimeoutSeconds, onTimeout: { await self.retireCurrentRequestGeneration(method: method) }, operation: { try await Self.withRequestRetry(risk: descriptor.risk) { attempt in - let runAttempt: @Sendable () async throws -> JSONValue = { + let runAttempt: @Sendable () async throws -> NativeReply = { let connection = try await self.ensureConnection() try await self.validateWorkspaceScope( method: method, params: normalizedRequest, connection: connection ) - return try await Self.sendReviewedRequest( + return try await self.sendNativeRequest( method: method, params: normalizedRequest, connection: connection @@ -945,30 +1003,113 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { "codex.app.request_failed: \(Self.errorDescription(error))" ) } - if let turnStartThreadID, - threadStates[turnStartThreadID] == .string("starting"), + guard connection === reply.connection else { + throw CodexToolError.executionFailed( + "codex.app.connection_retired: The response belongs to a retired connection.") + } + let response = reply.value + let returnedTurnThreadID = + isDetachedReview + ? Self.safeStoredIdentifier(response.objectValue?["reviewThreadId"]?.stringValue) + : turnStartThreadID + if let threadID = returnedTurnThreadID, + let turnID = Self.safeStoredIdentifier( + response.objectValue?["turn"]?.objectValue?["id"]?.stringValue) + { + if method == "thread/queue/start" { + if let origin = queuedWork.origin( + threadID: threadID, + submissionID: normalizedRequest?.objectValue?["queuedSubmissionId"]?.stringValue) + { + turnBinding(threadID: threadID, turnID: turnID).origin.bind(to: origin.value) + } else if !queuedWork.hasPending(threadID: threadID) { + turnBinding(threadID: threadID, turnID: turnID).origin.bind(to: creator) + } + } else { + turnBinding(threadID: threadID, turnID: turnID).origin.bind(to: creator) + } + } + if let returnedTurnThreadID, + isDetachedReview || threadStates[returnedTurnThreadID] == .string("starting"), let turnID = Self.safeStoredIdentifier( response.objectValue?["turn"]?.objectValue?["id"]?.stringValue ) { let responseStatus = response.objectValue?["turn"]?.objectValue?["status"]?.stringValue - if let responseStatus, ["completed", "failed", "interrupted"].contains(responseStatus) { - activeTurnIDs.removeValue(forKey: turnStartThreadID) - threadStates[turnStartThreadID] = .string("idle") - } else { - activeTurnIDs[turnStartThreadID] = turnID - threadStates[turnStartThreadID] = .string("active") + if completedTurnsDuringStart.contains(.init(threadID: returnedTurnThreadID, turnID: turnID)) + || closedThreadsDuringStart.contains(returnedTurnThreadID) + || responseStatus.map({ ["completed", "failed", "interrupted"].contains($0) }) == true + { + if activeTurnIDs[returnedTurnThreadID] == nil + || activeTurnIDs[returnedTurnThreadID] == turnID + { + activeTurnIDs.removeValue(forKey: returnedTurnThreadID) + threadStates[returnedTurnThreadID] = .string("idle") + } + } else if activeTurnIDs[returnedTurnThreadID] == nil + || activeTurnIDs[returnedTurnThreadID] == turnID + { + activeTurnIDs[returnedTurnThreadID] = turnID + threadStates[returnedTurnThreadID] = .string("active") } } let visibleResponse = try threadOwnerIndex?.filtered(response, method: method) ?? response try rememberWorkspaceScopedThreads( method: method, params: normalizedRequest, - response: visibleResponse + response: visibleResponse, creator: creator, previouslyOwnedThreads: previouslyOwnedThreads ) + if method == "thread/goal/set", + let threadID = normalizedRequest?.objectValue?["threadId"]?.stringValue + { + goalObservationToken = UUID() + if response.objectValue?["goal"]?.objectValue?["status"] == .string("active") { + if goalWork[threadID] == nil { goalWork[threadID] = .init(origin: work.origin) } + if queriedGoalWorkID == nil { goalWork[threadID]?.origin.bind(to: creator) } + if goalNotificationsDuringMutation.contains(threadID) { uncertainGoalWork.insert(threadID) } + } else { + finishGoalMutation(threadID: threadID) + } + } else if method == "thread/goal/clear", + let threadID = normalizedRequest?.objectValue?["threadId"]?.stringValue, + response.objectValue?["cleared"] == .bool(true) + { + goalObservationToken = UUID() + finishGoalMutation(threadID: threadID) + } else if method == "thread/goal/get", let threadID = targetThreadID, + let queriedGoalWorkID, goalWork[threadID]?.id == queriedGoalWorkID, + queriedGoalObservation == goalObservationToken, !goalMutationInFlight.contains(threadID) + { + if response.objectValue?["goal"]?.objectValue?["status"] != .string("active") { + goalWork.removeValue(forKey: threadID) + } + uncertainGoalWork.remove(threadID) + } + if method == "thread/turns/list" || method == "thread/items/list" { + // A truncated page loses both records and its continuation. The caller + // keeps its input cursor and can retry a smaller page without skipping data. + guard try JSONEncoder().encode(visibleResponse).count <= outputBounds.maxStructuredBytes + else { + throw CodexToolError.executionFailed( + "codex.app.history_page_too_large: Retry the same cursor with a smaller limit or itemsView=notLoaded for turns. If a single item exceeds the output budget, use codex.app.thread.recent for an explicitly bounded summary." + ) + } + return visibleResponse + } return outputBounds.json(visibleResponse) } + private func finishGoalMutation(threadID: String) { + // Notification and RPC consumers can interleave; a contradictory active + // notification needs a subsequent read before the owner can be released. + if goalNotificationsDuringMutation.contains(threadID), goalWork[threadID] != nil { + uncertainGoalWork.insert(threadID) + } else { + goalWork.removeValue(forKey: threadID) + uncertainGoalWork.remove(threadID) + } + } + private func validateHandoff( threadID: String, mode: CodexThreadHandoffMode, @@ -980,6 +1121,15 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { ) } guard mode == .graceful else { return } + guard + !serverRequestWork.values.contains(where: { $0.threadID == threadID && $0.handlerActive }), + !asyncNativeWork.hasWork(threadID: threadID) + && !queuedWork.hasPending(threadID: threadID) + else { + throw CodexToolError.disabled( + "codex.app.handoff_pending_work: The thread still owns a running callback or background session." + ) + } let approvals = approvalRecords.values.filter { $0.threadID == threadID && $0.state == .pending }.count @@ -1098,16 +1248,22 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { "codex.app.request_unknown: Unknown or already resolved App Server request '\(requestID)'." ) } - let connection = try await ensureConnection() + let connection = request.connection do { + guard self.connection === connection, !isShutdown else { + throw CodexToolError.disabled( + "codex.app.request_retired: The request's connection has retired.") + } switch request.handle.method { case "item/tool/requestUserInput": _ = try Self.decodeUserInputResponse(response) case "mcpServer/elicitation/request": _ = try Self.decodeElicitationResponse(response) default: throw CodexToolError.invalidArguments("Unsupported interactive request.") } - try await connection.resolveServerRequest(request.handle, with: response) + try await resolveOwnedServerRequest(request.handle, connection: connection, with: response) } catch { - pendingUserInputRequests[requestID] = request + if self.connection === connection, !isShutdown { + pendingUserInputRequests[requestID] = request + } throw error } await eventBuffer.append( @@ -1186,18 +1342,19 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { private func shutdown(reason: String) async { if isShutdown { + await recheckProcessCleanup() return } isShutdown = true + for work in serverRequestWork.values { work.task?.cancel() } + let retiredGeneration = connectionGeneration shutdownReason = reason if let requestGenerationRetirement { await finishRequestGenerationRetirement(requestGenerationRetirement) shutdownReason = reason } notificationTask?.cancel() - requestTask?.cancel() notificationTask = nil - requestTask = nil let activeConnection = connection let activeTransport = processTransport let startup = connectionStartup @@ -1215,11 +1372,6 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { } approvalTimeoutTasks.removeAll() pendingApprovalHandles.removeAll() - workspaceScopedThreadIDs.removeAll() - loadedThreadIDs.removeAll() - subscribedThreadIDs.removeAll() - threadStates.removeAll() - activeTurnIDs.removeAll() connectionState = "stopped" lastError = nil startup?.task.cancel() @@ -1231,8 +1383,14 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { } else if let activeTransport { lastProcessSnapshot = await activeTransport.snapshot() } + if lastProcessSnapshot?.cleanupConfirmed == true { + releaseConfirmedGeneration(retiredGeneration) + } else if let transport = startup?.transport ?? activeTransport { + unconfirmedTransport = (retiredGeneration, transport) + cleanupWork = startup?.work ?? .init(origin: .init(invocation: CodexWorkInvocation.current)) + } persistRuntimeLease(state: "stopped", reason: shutdownReason) - CodexRuntimeDirectory.shared.unregister(id: runtimeID) + unregisterIfDrained() _ = try? CodexThreadOwnershipReconciliation.reconcileSafely( database: database, workspaceID: owner?.workspaceID, @@ -1258,6 +1416,12 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { if let connection { return connection } + await recheckProcessCleanup() + guard unconfirmedTransport == nil, lastProcessSnapshot?.cleanupConfirmed != false else { + throw CodexToolError.disabled( + "codex.app.cleanup_unconfirmed: The previous owned process group has not been confirmed gone." + ) + } connectionState = "starting" shutdownReason = nil lastError = nil @@ -1290,10 +1454,7 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { version: CodexAdapterBuildInfo.version ), experimentalApi: configuration.experimentalAPI, - optOutNotificationMethods: [ - "remoteControl/status/changed" - ], - inboundMessageMode: .raw + inboundMessageMode: .rawOrdered ), transportFactory: { transport } ) @@ -1304,7 +1465,9 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { } return connection } - startup = .init(id: UUID(), transport: transport, task: task) + startup = .init( + id: UUID(), work: .init(origin: .init(invocation: CodexWorkInvocation.current)), + transport: transport, task: task) connectionStartup = startup } do { @@ -1320,6 +1483,7 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { connection = started processTransport = startup.transport connectionGeneration += 1 + remoteControlWork.started(generation: connectionGeneration, origin: startup.work.origin) connectionID = UUID().uuidString connectionState = "running" shutdownReason = nil @@ -1333,7 +1497,7 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { "experimental_api": .bool(configuration.experimentalAPI), "runtime_id": .string(runtimeID), "connection_id": connectionID.map(JSONValue.string) ?? .null, - "connection_generation": .number(Double(connectionGeneration)), + "connection_generation": .integer(Int64(connectionGeneration)), "process": processSnapshot.json, ]) ) @@ -1354,6 +1518,10 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { throw RequestTimeoutError(seconds: configuration.appServerRequestTimeoutSeconds) } lastProcessSnapshot = startupProcessSnapshot + if startupProcessSnapshot.cleanupConfirmed != true { + unconfirmedTransport = (connectionGeneration, startup.transport) + cleanupWork = startup.work + } throw await connectionStartupFailed(error) } } @@ -1370,35 +1538,47 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { } private func startConsumers(connection: CodexAppServerConnection) { + CodexWorkInvocation.$current.withValue(nil) { + startUnboundConsumers(connection: connection) + } + } + + private func startUnboundConsumers(connection: CodexAppServerConnection) { notificationTask?.cancel() - requestTask?.cancel() notificationTask = Task { [weak self, connection] in do { - for try await notification in connection.rawNotifications { + for try await message in connection.rawInboundMessages { guard let self else { return } - await self.recordNotification(notification) + switch message { + case .notification(let notification): + await self.recordNotification(notification, connection: connection) + case .serverRequest(let request): + await self.enqueueServerRequest(request, connection: connection) + } } await self?.connectionEnded(connection, message: nil) } catch { await self?.connectionEnded(connection, message: Self.errorDescription(error)) } } - requestTask = Task { [weak self, connection] in - do { - for try await request in connection.rawServerRequests { - guard let self else { return } - await self.handleServerRequest(request, connection: connection) - } - } catch { - await self?.recordConsumerFailure( - kind: "server_request_stream_failed", - message: error.localizedDescription - ) - } - } } - private func recordNotification(_ notification: CodexAppServerRawNotification) async { + private func recordNotification( + _ notification: CodexAppServerRawNotification, connection: CodexAppServerConnection + ) async { + guard self.connection === connection else { return } + let rawParams = (try? Self.gatewayJSON(notification.payload))?.objectValue?["params"]? + .objectValue + asyncNativeWork.notified( + method: notification.method, params: rawParams ?? [:], generation: connectionGeneration) + if notification.method == "remoteControl/status/changed" { + remoteControlWork.notified(params: rawParams ?? [:], generation: connectionGeneration) + } + if notification.method == "process/exited", + let handle = rawParams?["processHandle"]?.stringValue + { + nativeResources.processExited(handle: handle, generation: connectionGeneration) + } let payload = CodexApprovalRedactor.redact( (try? Self.gatewayJSON(notification.payload)) ?? .null) let params = payload.objectValue?["params"]?.objectValue ?? [:] @@ -1413,30 +1593,127 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { workspaceScopedThreadIDs.insert(threadID) loadedThreadIDs.insert(threadID) subscribedThreadIDs.insert(threadID) + _ = threadBinding(threadID) case "thread/status/changed": threadStates[threadID] = params["status"] ?? .string("unknown") case "thread/closed": + let creationPending = + threadStartInFlight || detachedReviewInFlight || !threadResumeInFlight.isEmpty + if creationPending { closedThreadsDuringStart.insert(threadID) } + threadCleanupWork = threadCleanupWork.filter { + $0.value.threadID != threadID || $0.value.requiresProcessExit + } loadedThreadIDs.remove(threadID) subscribedThreadIDs.remove(threadID) activeTurnIDs.removeValue(forKey: threadID) threadStates[threadID] = .string("closed") + if !creationPending { threadWork.removeValue(forKey: threadID) } + if threadCleanupWork.values.contains(where: { $0.threadID == threadID }) { + if goalWork[threadID] != nil { uncertainGoalWork.insert(threadID) } + } else { + goalWork.removeValue(forKey: threadID) + uncertainGoalWork.remove(threadID) + } case "turn/started": if let turnID = Self.safeStoredIdentifier(params["turn"]?.objectValue?["id"]?.stringValue) { activeTurnIDs[threadID] = turnID + _ = requestOrigin(threadID: threadID, turnID: turnID) threadStates[threadID] = .string("active") } case "turn/completed": - activeTurnIDs.removeValue(forKey: threadID) - threadStates[threadID] = .string("idle") + if let turnID = params["turn"]?.objectValue?["id"]?.stringValue { + queuedWork.completed(threadID: threadID, turnID: turnID, generation: connectionGeneration) + if detachedReviewInFlight || turnStartInFlight.contains(threadID) { + completedTurnsDuringStart.insert(.init(threadID: threadID, turnID: turnID)) + } + if activeTurnIDs[threadID] == turnID { + activeTurnIDs.removeValue(forKey: threadID) + threadStates[threadID] = .string("idle") + } + } + case "item/started": + if let item = rawParams?["item"]?.objectValue, item["type"] == .string("userMessage"), + let turnID = Self.safeStoredIdentifier(rawParams?["turnId"]?.stringValue) + { + let queuedOrigin = item["clientId"]?.stringValue.flatMap { + queuedWork.consumed( + threadID: threadID, clientID: $0, turnID: turnID, generation: connectionGeneration) + } + if queuedOrigin != nil || !turnStartInFlight.contains(threadID) { + let origin = + queuedOrigin ?? goalWork[threadID]?.origin ?? pendingGoalOrigins[threadID] + ?? threadBinding(threadID).origin + turnBinding(threadID: threadID, turnID: turnID).origin.bind(to: origin.value) + } + } + case "thread/goal/updated": + goalObservationToken = UUID() + if goalMutationInFlight.contains(threadID) { + goalNotificationsDuringMutation.insert(threadID) + } else { + uncertainGoalWork.remove(threadID) + } + if params["goal"]?.objectValue?["status"] == .string("active") { + if goalWork[threadID] == nil { + goalWork[threadID] = .init(origin: pendingGoalOrigins[threadID] ?? .init()) + } + } else { + goalWork.removeValue(forKey: threadID) + } + case "thread/goal/cleared": + goalObservationToken = UUID() + if goalMutationInFlight.contains(threadID) { + goalNotificationsDuringMutation.insert(threadID) + } + goalWork.removeValue(forKey: threadID) + uncertainGoalWork.remove(threadID) default: break } + if !turnStartInFlight.contains(threadID) { pruneWorkBindings() } } await eventBuffer.append(kind: "notification", payload: payload) } + private func enqueueServerRequest( + _ request: CodexAppServerRawServerRequest, connection: CodexAppServerConnection + ) async { + guard self.connection === connection else { return } + let id = Self.requestIDString(request.id) + let params = (try? Self.gatewayJSON(request.params)) ?? .null + let threadID = Self.serverRequestThreadID(params) + let turnID = Self.safeStoredIdentifier(params.objectValue?["turnId"]?.stringValue) + let workKey = ServerRequestWorkKey(connection: ObjectIdentifier(connection), requestID: id) + let binding = CodexWorkBinding(origin: requestOrigin(threadID: threadID, turnID: turnID)) + serverRequestWork[workKey] = .init( + binding: binding, connection: connection, generation: connectionGeneration, + threadID: threadID, turnID: turnID) + // Registration precedes the next wire message; slow host calls do not hold + // the inbound consumer or hide queued requests behind turn completion. + if serverRequestWork.count > 256 { + do { + try await rejectOwnedServerRequest( + request, connection: connection, code: -32_000, + message: "The adapter's pending server-request capacity is reached.") + } catch { + await connectionEnded( + connection, message: "The server-request rejection could not be sent.") + } + finishServerRequestHandler(id: workKey, workID: binding.id) + return + } + serverRequestWork[workKey]?.task = Task { + await self.handleServerRequest(request, connection: connection) + self.finishServerRequestHandler(id: workKey, workID: binding.id) + } + } + private func handleServerRequest( _ request: CodexAppServerRawServerRequest, connection: CodexAppServerConnection ) async { + guard self.connection === connection else { + await rejectServerRequest(request, method: request.method, connection: connection) + return + } let id = Self.requestIDString(request.id) let params = (try? Self.gatewayJSON(request.params)) ?? .null let payload = CodexApprovalRedactor.redact((try? Self.gatewayJSON(request.payload)) ?? .null) @@ -1449,7 +1726,8 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { switch request.method { case "item/tool/requestUserInput", "mcpServer/elicitation/request": pendingUserInputRequests[id] = .init( - handle: request, payload: payload, threadID: Self.serverRequestThreadID(params)) + handle: request, connection: connection, payload: payload, + threadID: Self.serverRequestThreadID(params)) await eventBuffer.append( kind: "user_input_requested", payload: .object(["request_id": .string(id), "request": payload])) @@ -1555,6 +1833,9 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { } pendingApprovalHandles[id] = handle + let workKey = ServerRequestWorkKey( + connection: ObjectIdentifier(connection), requestID: upstreamRequestID) + serverRequestWork[workKey]?.approvalID = id await eventBuffer.append(kind: "approval_requested", payload: record.json) approvalTimeoutTasks[id] = Task { [weak self] in @@ -1588,13 +1869,13 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { let result = try await dynamicToolDispatcher.execute( name: params.tool, arguments: arguments, requestID: params.callId, workspaceID: owner?.workspaceID) - try await connection.resolveServerRequest( - handle, + try await resolveOwnedServerRequest( + handle, connection: connection, with: Self.dynamicToolResponse( success: result.objectValue?["isError"] != .bool(true), value: result)) } catch { - try? await connection.resolveServerRequest( - handle, + try? await resolveOwnedServerRequest( + handle, connection: connection, with: Self.dynamicToolResponse( success: false, value: .object(["error": .string(Self.errorDescription(error))]))) @@ -1638,7 +1919,7 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { do { // Remove before suspension so concurrent responders cannot both send. pendingApprovalHandles.removeValue(forKey: id) - try await connection.resolveServerRequest(handle, with: response) + try await resolveOwnedServerRequest(handle, connection: connection, with: response) let decision = response.objectValue?["decision"] record.state = Self.approvalState(response) record.resolvedAt = Date() @@ -1768,6 +2049,74 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { && (record.owner == nil || record.owner?.profileID == owner?.profileID) } + private func resolveOwnedServerRequest( + _ handle: CodexAppServerRawServerRequest, connection: CodexAppServerConnection, + with response: Response + ) async throws { + let id = ServerRequestWorkKey( + connection: ObjectIdentifier(connection), requestID: Self.requestIDString(handle.id)) + let work = serverRequestWork[id] + do { + try await connection.resolveServerRequest(handle, with: response) + finishServerRequestWork(id: id, work: work, confirmed: true) + } catch { + finishServerRequestWork(id: id, work: work, confirmed: false) + throw error + } + } + + private func rejectOwnedServerRequest( + _ handle: CodexAppServerRawServerRequest, connection: CodexAppServerConnection, + code: Int64, message: String + ) async throws { + let id = ServerRequestWorkKey( + connection: ObjectIdentifier(connection), requestID: Self.requestIDString(handle.id)) + let work = serverRequestWork[id] + do { + try await connection.rejectServerRequest(handle, code: code, message: message) + finishServerRequestWork(id: id, work: work, confirmed: true) + } catch { + finishServerRequestWork(id: id, work: work, confirmed: false) + throw error + } + } + + private func cancelServerRequestHandlers(generation: Int) { + for work in serverRequestWork.values where work.generation == generation { work.task?.cancel() } + } + + private func finishServerRequestWork( + id: ServerRequestWorkKey, work: ServerRequestWork?, confirmed: Bool + ) { + guard let work, serverRequestWork[id]?.binding.id == work.binding.id else { return } + if confirmed { + serverRequestWork[id]?.nativeSettled = true + } else { + serverRequestWork[id]?.state = .uncertain + } + releaseSettledServerRequest(id: id) + } + + private func finishServerRequestHandler(id: ServerRequestWorkKey, workID: String?) { + guard let workID, serverRequestWork[id]?.binding.id == workID else { return } + serverRequestWork[id]?.handlerActive = false + releaseSettledServerRequest(id: id) + } + + private func releaseSettledServerRequest(id: ServerRequestWorkKey) { + if let work = serverRequestWork[id], work.nativeSettled && !work.handlerActive { + serverRequestWork.removeValue(forKey: id) + } + pruneWorkBindings() + unregisterIfDrained() + } + + private func unregisterIfDrained() { + if isShutdown, unconfirmedTransport == nil, serverRequestWork.isEmpty { + CodexRuntimeDirectory.shared.unregister(id: runtimeID) + } + } + private func rejectServerRequest( _ handle: CodexAppServerRawServerRequest, method: String, @@ -1778,8 +2127,8 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { Self.serverRequestPayload(method: method, params: handle.params) ) do { - try await connection.rejectServerRequest( - handle, + try await rejectOwnedServerRequest( + handle, connection: connection, code: -32_001, message: "This App Server request is not part of the supported coding contract." @@ -1802,7 +2151,8 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { message: String ) async { do { - try await connection.rejectServerRequest(handle, code: -32_001, message: message) + try await rejectOwnedServerRequest( + handle, connection: connection, code: -32_001, message: message) } catch { await recordConsumerFailure( kind: "approval_policy_rejection_failed", @@ -1889,7 +2239,7 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { default: response = .object(["decision": .string("cancel")]) } - try await connection.resolveServerRequest(handle, with: response) + try await resolveOwnedServerRequest(handle, connection: connection, with: response) } private static func dynamicToolResponse( @@ -1914,42 +2264,43 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { _ endedConnection: CodexAppServerConnection, message: String? ) async { + // Explicit shutdown owns cleanup after cancelling the notification consumer. + guard !isShutdown else { return } guard connection === endedConnection else { await endedConnection.close() return } - await interruptPendingApprovals( - connection: endedConnection, - reason: message == nil ? "App Server connection ended." : "App Server connection failed." - ) let endedTransport = processTransport + let endedGeneration = connectionGeneration + cancelServerRequestHandlers(generation: endedGeneration) connection = nil connectionID = nil processTransport = nil - pendingUserInputRequests.removeAll() - workspaceScopedThreadIDs.removeAll() connectionState = message == nil ? "stopped" : "failed" shutdownReason = nil let redactedMessage = message.map(Self.redactedMessage) lastError = redactedMessage recordRequestFailure( kind: message == nil ? "peer_closed" : "consumer_failure", - message: redactedMessage ?? "App Server connection ended." - ) + message: redactedMessage ?? "App Server connection ended.") + let retirement = RequestGenerationRetirement( + id: UUID(), work: .init(origin: .init(invocation: CodexWorkInvocation.current)), + generation: endedGeneration, + transport: endedTransport, + task: Task { + await self.interruptPendingApprovals( + connection: endedConnection, + reason: message == nil ? "App Server connection ended." : "App Server connection failed.") + await endedConnection.close() + await endedTransport?.close() + return await endedTransport?.snapshot() + }) + requestGenerationRetirement = retirement + pendingUserInputRequests.removeAll() await eventBuffer.append( kind: "connection_ended", - payload: .object(["message": redactedMessage.map(JSONValue.string) ?? .null]) - ) - await endedConnection.close() - await endedTransport?.close() - if let endedTransport { - lastProcessSnapshot = await endedTransport.snapshot() - } - persistRuntimeLease( - state: connectionState, - process: lastProcessSnapshot, - reason: nil - ) + payload: .object(["message": redactedMessage.map(JSONValue.string) ?? .null])) + await finishRequestGenerationRetirement(retirement) } private func retireCurrentRequestGeneration(method: String? = nil) async { @@ -1959,19 +2310,13 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { } if let connection { let transport = processTransport - await interruptPendingApprovals( - connection: connection, - reason: "App Server request deadline exceeded." - ) + cancelServerRequestHandlers(generation: connectionGeneration) self.connection = nil connectionID = nil processTransport = nil notificationTask?.cancel() notificationTask = nil - requestTask?.cancel() - requestTask = nil pendingUserInputRequests.removeAll() - workspaceScopedThreadIDs.removeAll() connectionState = "failed" shutdownReason = nil lastError = "App Server request deadline exceeded." @@ -1980,19 +2325,23 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { message: method.map { "App Server request '\($0)' exceeded its deadline." } ?? "App Server request deadline exceeded." ) - await eventBuffer.append( - kind: "connection_ended", - payload: .object(["message": .string("App Server request deadline exceeded.")]) - ) let retirement = RequestGenerationRetirement( - id: UUID(), + id: UUID(), work: .init(origin: .init(invocation: CodexWorkInvocation.current)), + generation: connectionGeneration, + transport: transport, task: Task { + await self.interruptPendingApprovals( + connection: connection, reason: "App Server request deadline exceeded.") await connection.close() await transport?.close() return await transport?.snapshot() } ) requestGenerationRetirement = retirement + await eventBuffer.append( + kind: "connection_ended", + payload: .object(["message": .string("App Server request deadline exceeded.")]) + ) await finishRequestGenerationRetirement(retirement) return } @@ -2019,7 +2368,9 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { ]) ) let retirement = RequestGenerationRetirement( - id: UUID(), + id: UUID(), work: startup.work, + generation: connectionGeneration, + transport: startup.transport, task: Task { await startup.transport.close() return await startup.transport.snapshot() @@ -2037,6 +2388,12 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { requestGenerationRetirement = nil if let processSnapshot { lastProcessSnapshot = processSnapshot + if processSnapshot.cleanupConfirmed == true { + releaseConfirmedGeneration(retirement.generation) + } else if let transport = retirement.transport { + unconfirmedTransport = (retirement.generation, transport) + cleanupWork = retirement.work + } } persistRuntimeLease( state: "failed", @@ -2045,6 +2402,41 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { ) } + private func recheckProcessCleanup() async { + guard let pending = unconfirmedTransport else { return } + let snapshot = await pending.transport.snapshot() + guard unconfirmedTransport?.generation == pending.generation else { return } + lastProcessSnapshot = snapshot + if snapshot.cleanupConfirmed == true { + releaseConfirmedGeneration(pending.generation) + unconfirmedTransport = nil + unregisterIfDrained() + } + } + + private func releaseConfirmedGeneration(_ generation: Int) { + nativeResources.retired(generation: generation) + asyncNativeWork.retired(generation: generation) + remoteControlWork.retired(generation: generation) + queuedWork.retired(generation: generation) + threadCleanupWork = threadCleanupWork.filter { $0.value.generation != generation } + for id in serverRequestWork.keys where serverRequestWork[id]?.generation == generation { + serverRequestWork[id]?.nativeSettled = true + if serverRequestWork[id]?.handlerActive == false { serverRequestWork.removeValue(forKey: id) } + } + guard generation == connectionGeneration else { return } + cleanupWork = nil + threadWork.removeAll() + turnWork.removeAll() + goalWork.removeAll() + uncertainGoalWork.removeAll() + workspaceScopedThreadIDs.removeAll() + loadedThreadIDs.removeAll() + subscribedThreadIDs.removeAll() + activeTurnIDs.removeAll() + threadStates.removeAll() + } + private func recordRequestFailure(kind: String, message: String) { lastRequestFailure = CodexRuntimeRequestFailure( kind: kind, @@ -2320,24 +2712,39 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { private func rememberWorkspaceScopedThreads( method: String, params: JSONValue?, - response: JSONValue + response: JSONValue, creator: UUID?, previouslyOwnedThreads: Set ) throws { if ["thread/start", "thread/resume", "thread/fork"].contains(method) { let threadID = try Self.createdThreadID( response: response ) - try persistThreadOwnership(threadID: threadID, state: .loaded) + let alreadyClosed = closedThreadsDuringStart.contains(threadID) + try persistThreadOwnership(threadID: threadID, state: alreadyClosed ? .released : .loaded) workspaceScopedThreadIDs.insert(threadID) - loadedThreadIDs.insert(threadID) - subscribedThreadIDs.insert(threadID) + if !previouslyOwnedThreads.contains(threadID) { + threadBinding(threadID).origin.bind(to: creator) + } + if !alreadyClosed { + loadedThreadIDs.insert(threadID) + subscribedThreadIDs.insert(threadID) + } } if method == "thread/loaded/list" { let visible = response.objectValue?["data"]?.arrayValue?.compactMap(\.stringValue) ?? [] let loaded = Set(try visible.filter { try threadOwnerIndex?.owns(threadID: $0) ?? true }) + let completeListing = + params?.objectValue?["cursor"]?.stringValue == nil + && response.objectValue?["nextCursor"]?.stringValue == nil + if completeListing { + let removed = loadedThreadIDs.union(subscribedThreadIDs).subtracting(loaded) + for id in removed { retainThreadCleanup(threadID: id) } + loadedThreadIDs = loaded + subscribedThreadIDs.formIntersection(loaded) + } else { + loadedThreadIDs.formUnion(loaded) + } workspaceScopedThreadIDs.formUnion(loaded) - loadedThreadIDs = loaded - subscribedThreadIDs = loaded for threadID in loaded where threadOwnerIndex == nil { try persistThreadOwnership(threadID: threadID, state: .loaded) } @@ -2345,12 +2752,23 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { if method == "thread/unsubscribe", let threadID = params?.objectValue?["threadId"]?.stringValue + { + // Unsubscribe only removes this connection's listener. Native shutdown + // can be delayed by active work or fail; thread/closed proves teardown. + subscribedThreadIDs.remove(threadID) + } + if (method == "thread/unsubscribe" && response.objectValue?["status"] == .string("notLoaded")) + || method == "thread/delete", + let threadID = params?.objectValue?["threadId"]?.stringValue { loadedThreadIDs.remove(threadID) subscribedThreadIDs.remove(threadID) activeTurnIDs.removeValue(forKey: threadID) - threadStates[threadID] = .string("released") - try persistThreadOwnership(threadID: threadID, state: .released) + threadWork.removeValue(forKey: threadID) + threadStates[threadID] = .string(method == "thread/delete" ? "deleted" : "released") + if method == "thread/delete" { workspaceScopedThreadIDs.remove(threadID) } + try persistThreadOwnership( + threadID: threadID, state: method == "thread/delete" ? .deleted : .released) } if method == "thread/archive", @@ -2366,17 +2784,27 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { } if method == "review/start", - let reviewThreadID = response.objectValue?["reviewThreadId"]?.stringValue, - !reviewThreadID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let reviewThreadID = Self.safeStoredIdentifier( + response.objectValue?["reviewThreadId"]?.stringValue) { + let alreadyClosed = closedThreadsDuringStart.contains(reviewThreadID) + try persistThreadOwnership( + threadID: reviewThreadID, state: alreadyClosed ? .released : .loaded) workspaceScopedThreadIDs.insert(reviewThreadID) + if !alreadyClosed { + loadedThreadIDs.insert(reviewThreadID) + subscribedThreadIDs.insert(reviewThreadID) + } + if !previouslyOwnedThreads.contains(reviewThreadID) { + threadBinding(reviewThreadID).origin.bind(to: creator) + } } } private func releaseAllThreads(connection: CodexAppServerConnection) async { for threadID in subscribedThreadIDs.sorted() { do { - _ = try await Self.boundedRequest( + let response = try await Self.boundedRequest( timeoutSeconds: min(2, configuration.appServerRequestTimeoutSeconds), onTimeout: {}, operation: { @@ -2388,10 +2816,13 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { ) } ) - loadedThreadIDs.remove(threadID) subscribedThreadIDs.remove(threadID) - threadStates[threadID] = .string("released") - try persistThreadOwnership(threadID: threadID, state: .released) + if response.status == .notloaded { + loadedThreadIDs.remove(threadID) + threadStates[threadID] = .string("released") + threadWork.removeValue(forKey: threadID) + try persistThreadOwnership(threadID: threadID, state: .released) + } } catch { await eventBuffer.append( kind: "thread_release_failed", @@ -2404,6 +2835,17 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { } } + private func retainThreadCleanup(threadID: String, requiresProcessExit: Bool = false) { + guard let binding = threadWork[threadID] else { return } + if threadCleanupWork[binding.id] != nil { + if requiresProcessExit { threadCleanupWork[binding.id]?.requiresProcessExit = true } + } else { + threadCleanupWork[binding.id] = .init( + threadID: threadID, generation: connectionGeneration, binding: binding, + requiresProcessExit: requiresProcessExit) + } + } + private func persistThreadOwnership( threadID: String, state: CodexThreadOwnershipState @@ -2440,7 +2882,14 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { method: String, params: JSONValue? ) throws -> String? { - guard threadScopedMethods.contains(method) else { + guard + let required = CodexAppServerMethodCatalog.method(named: method)?.threadParameters["threadId"] + else { + return nil + } + if !required, + params?.objectValue?["threadId"] == nil || params?.objectValue?["threadId"] == .null + { return nil } guard let rawThreadID = params?.objectValue?["threadId"]?.stringValue else { @@ -2515,29 +2964,6 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { || resolvedCandidate.path.hasPrefix(resolvedRoot.path + "/") } - private static let threadScopedMethods: Set = [ - "review/start", - "thread/archive", - "thread/compact/start", - "thread/fork", - "thread/goal/clear", - "thread/goal/get", - "thread/goal/set", - "thread/inject_items", - "thread/metadata/update", - "thread/name/set", - "thread/read", - "thread/turns/list", - "thread/items/list", - "thread/resume", - "thread/rollback", - "thread/unarchive", - "thread/unsubscribe", - "turn/interrupt", - "turn/start", - "turn/steer", - ] - private static func sandboxPolicy(_ mode: CodexSandboxMode) -> JSONValue { switch mode { case .readOnly: @@ -2627,6 +3053,57 @@ actor LiveCodexAppServerRuntime: CodexAppServerRuntimeProtocol { return try gatewayJSON(try await connection.sendRawRequest(method: method)) } + private func sendNativeRequest( + method: String, params: JSONValue?, connection: CodexAppServerConnection + ) async throws -> NativeReply { + if let beforeThreadID = params?.objectValue?["beforeThreadId"]?.stringValue, + CodexAppServerMethodCatalog.method(named: method)?.threadParameters["beforeThreadId"] != nil + { + try await validateWorkspaceScope( + method: "thread/read", + params: .object(["threadId": .string(beforeThreadID)]), connection: connection) + } + guard self.connection === connection else { throw CodexAppServerClientError.closed } + let requestGeneration = connectionGeneration + let ticket = try nativeResources.prepare( + method: method, params: params, generation: connectionGeneration) + let asyncTicket = try asyncNativeWork.prepare( + method: method, params: params, generation: connectionGeneration) + remoteControlWork.prepare(method: method, generation: connectionGeneration) + let queueTicket = try queuedWork.prepare( + method: method, params: params, generation: requestGeneration) + if ["thread/archive", "thread/delete", "thread/revert"].contains(method), + let threadID = params?.objectValue?["threadId"]?.stringValue + { + // Native destructive operations remove the thread and emit thread/closed + // even when their bounded shutdown fails. Only process cleanup proves + // that the detached native task no longer owns work. + retainThreadCleanup(threadID: threadID, requiresProcessExit: true) + } + do { + let result = try await Self.sendReviewedRequest( + method: method, params: params, connection: connection) + nativeResources.completed(ticket) + asyncNativeWork.replied(asyncTicket, response: result) + queuedWork.replied(queueTicket, response: result) + queuedWork.deleted( + method: method, params: params, response: result, generation: requestGeneration) + return .init(connection: connection, value: result) + } catch { + if let nativeError = error as? CodexAppServerClientError, case .jsonRPCError = nativeError { + nativeResources.completed(ticket, rejected: true) + asyncNativeWork.failed(asyncTicket, rejected: true) + queuedWork.failed(queueTicket, rejected: true) + } else { + nativeResources.uncertain(ticket) + asyncNativeWork.failed(asyncTicket, rejected: false) + queuedWork.failed(queueTicket, rejected: false) + } + // An uncertain send retains its handle; it cannot be replayed onto a new connection. + throw error + } + } + private static func requestIDString( _ id: CodexAppServerProtocol.Stable.RequestId ) -> String { diff --git a/Sources/CodexAdapter/CodexApprovalRedactor.swift b/Sources/CodexAdapter/CodexApprovalRedactor.swift index 6e6c2e0..e29f095 100644 --- a/Sources/CodexAdapter/CodexApprovalRedactor.swift +++ b/Sources/CodexAdapter/CodexApprovalRedactor.swift @@ -7,6 +7,11 @@ enum CodexApprovalRedactor { #"(?i)((?:api[_-]?key|token|credential|password|secret)\s*[=:]\s*)[^\s,;]+"#, ].map { try! NSRegularExpression(pattern: $0) } + private static let tokenCounterKeys: Set = [ + "cachewriteinputtokens", "cachedinputtokens", "inputtokens", "outputtokens", + "reasoningoutputtokens", "totaltokens", "tokenbudget", "tokensused", + ] + static func redact(_ value: JSONValue) -> JSONValue { var remainingEntries = 10_000 return redact(value, depth: 0, remainingEntries: &remainingEntries) @@ -30,10 +35,11 @@ enum CodexApprovalRedactor { break } let safeKey = redactString(key, maximumCharacters: 256) + let value = object[key] ?? .null result[safeKey] = - isSensitiveKey(key) + isSensitiveValue(value, forKey: key) ? .string("[REDACTED]") - : redact(object[key] ?? .null, depth: depth + 1, remainingEntries: &remainingEntries) + : redact(value, depth: depth + 1, remainingEntries: &remainingEntries) } return .object(result) case .array(let values): @@ -48,7 +54,7 @@ enum CodexApprovalRedactor { return .array(result) case .string(let value): return .string(redactString(value)) - case .number, .bool, .null: + case .number, .integer, .bool, .null: return value } } @@ -61,10 +67,22 @@ enum CodexApprovalRedactor { return String(redactedString(value).prefix(maximumCharacters)) } - private static func isSensitiveKey(_ key: String) -> Bool { + private static func isSensitiveValue(_ value: JSONValue, forKey key: String) -> Bool { let normalized = key.lowercased() - return ["authorization", "credential", "password", "secret", "token"] - .contains { normalized.contains($0) } + let measurementKey = normalized.replacingOccurrences(of: "_", with: "") + // Usage containers still recurse through credential redaction. Only typed + // counters are public measurements; strings and other shapes remain sensitive. + if measurementKey == "tokenusage", case .object = value { return false } + if tokenCounterKeys.contains(measurementKey) { + switch value { + case .integer, .null: return false + default: break + } + } + return [ + "authorization", "credential", "password", "secret", "token", "api_key", "api-key", "apikey", + ] + .contains { normalized.contains($0) } } private static func redactedString(_ value: String) -> String { diff --git a/Sources/CodexAdapter/CodexConfig.swift b/Sources/CodexAdapter/CodexConfig.swift index 6db1a47..57cb6e7 100644 --- a/Sources/CodexAdapter/CodexConfig.swift +++ b/Sources/CodexAdapter/CodexConfig.swift @@ -141,36 +141,45 @@ package struct CodexConfig: Codable, Equatable, Sendable { } func resolvedExecutableURL(workspaceURL: URL, environment: [String: String]) throws -> URL { - let candidates: [URL] - if executable.contains("/") { - candidates = [ - executable.hasPrefix("/") - ? URL(fileURLWithPath: executable) - : workspaceURL.appendingPathComponent(executable) - ] - } else if let path = environment["PATH"] { - candidates = path.split(separator: ":", omittingEmptySubsequences: false).map { entry in - let directory = - entry.hasPrefix("/") - ? URL(fileURLWithPath: String(entry), isDirectory: true) - : workspaceURL.appendingPathComponent(String(entry), isDirectory: true) - return directory.appendingPathComponent(executable) + #if os(Windows) + do { + return try WindowsExecutable.resolve( + executable, workspace: workspaceURL, environment: environment) + } catch { + throw ConfigurationError.invalid(error.localizedDescription) } - } else { - candidates = [] - } - for candidate in candidates { - let url = candidate.standardizedFileURL - var isDirectory: ObjCBool = false - if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), - !isDirectory.boolValue, FileManager.default.isExecutableFile(atPath: url.path) - { - return url + #else + let candidates: [URL] + if executable.contains("/") { + candidates = [ + executable.hasPrefix("/") + ? URL(fileURLWithPath: executable) + : workspaceURL.appendingPathComponent(executable) + ] + } else if let path = environment["PATH"] { + candidates = path.split(separator: ":", omittingEmptySubsequences: false).map { entry in + let directory = + entry.hasPrefix("/") + ? URL(fileURLWithPath: String(entry), isDirectory: true) + : workspaceURL.appendingPathComponent(String(entry), isDirectory: true) + return directory.appendingPathComponent(executable) + } + } else { + candidates = [] } - } - throw ConfigurationError.invalid( - "Cannot resolve configured Codex executable '\(executable)' in the launch workspace and PATH." - ) + for candidate in candidates { + let url = candidate.standardizedFileURL + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + !isDirectory.boolValue, FileManager.default.isExecutableFile(atPath: url.path) + { + return url + } + } + throw ConfigurationError.invalid( + "Cannot resolve configured Codex executable '\(executable)' in the launch workspace and PATH." + ) + #endif } } diff --git a/Sources/CodexAdapter/CodexDatabase.swift b/Sources/CodexAdapter/CodexDatabase.swift index 99fc86d..6f4fe1f 100644 --- a/Sources/CodexAdapter/CodexDatabase.swift +++ b/Sources/CodexAdapter/CodexDatabase.swift @@ -12,6 +12,15 @@ final class CodexDatabase: @unchecked Sendable { fileURL = URL(fileURLWithPath: path).standardizedFileURL var configuration = Configuration() configuration.busyMode = .timeout(5) + #if os(Windows) + let directories = try ([path] + [worktreeLeasePath].compactMap { $0 }).map { + try WindowsPrivateDirectory(URL(fileURLWithPath: $0).deletingLastPathComponent()) + } + // GRDB retains configuration through connection close, keeping ancestry handles alive. + configuration.prepareDatabase { [directories] _ in + for directory in directories { try directory.validate() } + } + #endif writer = try DatabaseQueue(path: path, configuration: configuration) sharesWorktreeLeases = worktreeLeasePath != nil if let worktreeLeasePath { diff --git a/Sources/CodexAdapter/CodexEventBuffer.swift b/Sources/CodexAdapter/CodexEventBuffer.swift index 1ed21ef..35502b4 100644 --- a/Sources/CodexAdapter/CodexEventBuffer.swift +++ b/Sources/CodexAdapter/CodexEventBuffer.swift @@ -59,7 +59,7 @@ struct CodexOutputBounds: Sendable { ) return .object([ "encoding": .string("json"), - "original_bytes": .number(Double(data.count)), + "original_bytes": .integer(Int64(data.count)), "preview": .string(preview.value), "truncated": .bool(true), ]) @@ -84,7 +84,7 @@ struct CodexBufferedEvent: Sendable { var json: JSONValue { .object([ - "cursor": .number(Double(cursor)), + "cursor": .integer(Int64(cursor)), "timestamp": .string(timestamp.formatted(Self.timestampFormat)), "kind": .string(kind), "payload": payload, @@ -148,15 +148,15 @@ actor CodexEventBuffer { ?? max(afterCursor, firstRetainedCursor - 1) let remaining = max(available.count - rows.count, 0) return .object([ - "after_cursor": .number(Double(afterCursor)), - "next_cursor": .number(Double(resultCursor)), + "after_cursor": .integer(Int64(afterCursor)), + "next_cursor": .integer(Int64(resultCursor)), "events": .array(rows), - "missed_events": .number(Double(missed)), - "returned_events": .number(Double(rows.count)), - "remaining_events": .number(Double(remaining)), + "missed_events": .integer(Int64(missed)), + "returned_events": .integer(Int64(rows.count)), + "remaining_events": .integer(Int64(remaining)), "result_truncated": .bool(remaining > 0), - "encoded_event_bytes": .number(Double(encodedEventBytes)), - "max_output_bytes": .number(Double(bounds.maxOutputBytes)), + "encoded_event_bytes": .integer(Int64(encodedEventBytes)), + "max_output_bytes": .integer(Int64(bounds.maxOutputBytes)), ]) } } diff --git a/Sources/CodexAdapter/CodexExecRuntime.swift b/Sources/CodexAdapter/CodexExecRuntime.swift index 014d0eb..08b3ab5 100644 --- a/Sources/CodexAdapter/CodexExecRuntime.swift +++ b/Sources/CodexAdapter/CodexExecRuntime.swift @@ -9,6 +9,8 @@ protocol CodexExecRuntimeProtocol: Sendable { func events(sessionID: String, afterCursor: Int, maxResults: Int) async throws -> JSONValue func result(sessionID: String) async throws -> JSONValue func cancel(sessionID: String) async throws -> JSONValue + func release(sessionID: String) async throws -> JSONValue + func workResources() async throws -> [CodexWorkResource] func shutdown() async } @@ -85,6 +87,7 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { private struct Session: Sendable { let id: String + let workInvocation: UUID? let operation: Operation let createdAt: Date let eventBuffer: CodexEventBuffer @@ -94,6 +97,7 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { var state: State var cancellationRequested = false var cleanupConfirmed = false + var settled = false var upstreamSessionID: String? var finalMessage: String? var termination: CodexExecTermination? @@ -103,13 +107,19 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { var waitTask: Task? } + private struct PendingLaunch: Sendable { + let workInvocation: UUID? + let task: Task + } + private let configuration: CodexConfig private let workspaceURL: URL private let outputBounds: CodexOutputBounds private let client: any CodexExecClientAdapter private let threadOwnerIndex: CodexThreadOwnerIndex? private var sessions: [String: Session] = [:] - private var pendingLaunches = 0 + private var pendingLaunches: [String: PendingLaunch] = [:] + private var isShutdown = false init( configuration: CodexConfig, @@ -142,12 +152,11 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { func start(prompt: String, model: String? = nil, options: JSONValue? = nil) async throws -> JSONValue { + try checkSessionAdmission() let validatedPrompt = try Self.validatedPrompt(prompt, required: true) let validatedModel = try Self.validatedModel(model) let native = try CodexExecOptions( options, model: validatedModel, configuration: configuration, workspaceURL: workspaceURL) - try reserveSessionSlot() - let request = CodexExecRunRequest( promptInput: native.stdin.map { .textWithStdinContext(prompt: validatedPrompt, stdin: $0) } ?? .text(validatedPrompt), @@ -156,26 +165,15 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { outputSchemaFile: native.outputSchemaFile, outputLastMessageFile: native.outputLastMessageFile ) - let handle: any CodexExecProcessHandleAdapter - do { - handle = try await client.run(request) - } catch { - pendingLaunches -= 1 - throw Self.runtimeError(for: error) - } - pendingLaunches -= 1 - - return await register( - handle: handle, - operation: .start, - requestedUpstreamSessionID: nil, - model: validatedModel - ) + return try await launch( + operation: .start, requestedUpstreamSessionID: nil, model: validatedModel + ) { [client] in try await client.run(request) } } func resume( upstreamSessionID: String, prompt: String?, model: String? = nil, options: JSONValue? = nil ) async throws -> JSONValue { + try checkSessionAdmission() let validatedSessionID = try Self.validatedUpstreamSessionID(upstreamSessionID) try threadOwnerIndex?.check(threadID: validatedSessionID) let validatedPrompt = try Self.validatedPrompt(prompt, required: false) @@ -183,7 +181,6 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { options, model: Self.validatedModel(model), configuration: configuration, workspaceURL: workspaceURL) try threadOwnerIndex?.claim(threadID: validatedSessionID) - try reserveSessionSlot() let request = CodexExecResumeRequest( selector: .sessionID(validatedSessionID), promptInput: native.stdin.map { input in @@ -194,21 +191,10 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { outputSchemaFile: native.outputSchemaFile, outputLastMessageFile: native.outputLastMessageFile ) - let handle: any CodexExecProcessHandleAdapter - do { - handle = try await client.resume(request) - } catch { - pendingLaunches -= 1 - throw Self.runtimeError(for: error) - } - pendingLaunches -= 1 - - return await register( - handle: handle, - operation: .resume, - requestedUpstreamSessionID: validatedSessionID, + return try await launch( + operation: .resume, requestedUpstreamSessionID: validatedSessionID, model: native.request.model - ) + ) { [client] in try await client.resume(request) } } func list() -> JSONValue { @@ -222,7 +208,7 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { .map(sessionSummary) return .object([ "sessions": .array(rows), - "max_sessions": .number(Double(configuration.maxSessions)), + "max_sessions": .integer(Int64(configuration.maxSessions)), ]) } @@ -252,7 +238,7 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { if let finalMessage = session.finalMessage { let bounded = outputBounds.text(finalMessage) result["final_message"] = .string(bounded.value) - result["final_message_original_bytes"] = .number(Double(bounded.originalBytes)) + result["final_message_original_bytes"] = .integer(Int64(bounded.originalBytes)) result["final_message_truncated"] = .bool(bounded.truncated) } else { result["final_message"] = .null @@ -293,20 +279,82 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { ]) } + func release(sessionID: String) throws -> JSONValue { + let session = try session(named: sessionID) + guard session.state.isTerminal, session.settled, pendingLaunches[sessionID] == nil else { + throw CodexExecRuntimeError( + code: "codex.exec.not_finished", message: "The exec session has not finished settling.") + } + guard session.cleanupConfirmed else { + throw CodexExecRuntimeError( + code: "codex.exec.cleanup_unconfirmed", message: "Native process cleanup is not confirmed.") + } + sessions.removeValue(forKey: sessionID) + return .object(["session_id": .string(sessionID), "released": .bool(true)]) + } + + func workResources() throws -> [CodexWorkResource] { + var resources = try sessions.values.map { session in + try CodexWorkResource( + kind: "codex.exec.session", id: session.id, acquiredBy: session.workInvocation, + state: session.state.isTerminal && !session.cleanupConfirmed ? .uncertain : .active) + } + for (id, launch) in pendingLaunches where sessions[id] == nil { + resources.append( + try CodexWorkResource( + kind: "codex.exec.session", id: id, acquiredBy: launch.workInvocation)) + } + return resources.sorted { $0.id < $1.id } + } + func shutdown() async { - let owned = sessions.values.filter { !$0.state.isTerminal } - for session in owned { _ = try? await cancel(sessionID: session.id) } + isShutdown = true + let launches = pendingLaunches.values.map(\.task) + for launch in launches { launch.cancel() } + for launch in launches { _ = await launch.result } + let owned = sessions.values.filter { !$0.state.isTerminal || $0.waitTask != nil } + for session in owned where !session.state.isTerminal { + _ = try? await cancel(sessionID: session.id) + } for session in owned { await session.waitTask?.value } - pendingLaunches = 0 + } + + private func launch( + operation: Operation, requestedUpstreamSessionID: String?, model: String?, + request: @escaping @Sendable () async throws -> any CodexExecProcessHandleAdapter + ) async throws -> JSONValue { + try reserveSessionSlot() + let id = UUID().uuidString.lowercased() + let workInvocation = CodexWorkInvocation.current + // The owner can cancel and join startup even before the SDK supplies a process handle. + let task = Task { + do { + try Task.checkCancellation() + let handle = try await request() + return await register( + sessionID: id, handle: handle, operation: operation, + requestedUpstreamSessionID: requestedUpstreamSessionID, + model: model, workInvocation: workInvocation) + } catch { + throw Self.runtimeError(for: error) + } + } + pendingLaunches[id] = .init(workInvocation: workInvocation, task: task) + defer { pendingLaunches.removeValue(forKey: id) } + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } } private func register( + sessionID: String, handle: any CodexExecProcessHandleAdapter, operation: Operation, requestedUpstreamSessionID: String?, - model: String? + model: String?, workInvocation: UUID? ) async -> JSONValue { - let sessionID = UUID().uuidString.lowercased() let now = Date() let eventBuffer = CodexEventBuffer( capacity: configuration.maxEventsPerSession, @@ -314,6 +362,7 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { ) let session = Session( id: sessionID, + workInvocation: workInvocation, operation: operation, createdAt: now, eventBuffer: eventBuffer, @@ -352,6 +401,10 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { registered.waitTask = waitTask sessions[sessionID] = registered } + if isShutdown || Task.isCancelled { + _ = try? await cancel(sessionID: sessionID) + await waitTask.value + } return sessionSummary(sessions[sessionID] ?? session) } @@ -441,13 +494,12 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { session.updatedAt = Date() session.termination = termination session.cleanupConfirmed = true - session.streamTask = nil - session.waitTask = nil sessions[sessionID] = session await session.eventBuffer.append( kind: session.state == .completed ? "session.completed" : "session.failed", payload: session.failure.map { errorJSON($0) } ?? terminationJSON(termination) ) + markSettled(sessionID: sessionID) } private func fail(sessionID: String, error: Error) async { @@ -463,20 +515,35 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { session.finalMessage = session.finalMessage ?? session.partialObservation?.finalMessageText session.upstreamSessionID = session.upstreamSessionID ?? session.partialObservation?.resolvedSessionID - session.streamTask = nil - session.waitTask = nil sessions[sessionID] = session await session.eventBuffer.append( kind: session.state == .cancelled ? "session.cancelled" : "session.failed", payload: errorJSON(runtimeError) ) + markSettled(sessionID: sessionID) + } + + private func markSettled(sessionID: String) { + sessions[sessionID]?.settled = true + sessions[sessionID]?.streamTask = nil + sessions[sessionID]?.waitTask = nil + } + + private func checkSessionAdmission() throws { + guard !isShutdown else { + throw CodexExecRuntimeError( + code: "codex.exec.stopped", message: "The Exec runtime has stopped accepting sessions.") + } + try Task.checkCancellation() } private func reserveSessionSlot() throws { - while sessions.count + pendingLaunches >= configuration.maxSessions { + try checkSessionAdmission() + let unregistered = pendingLaunches.keys.filter { sessions[$0] == nil }.count + while sessions.count + unregistered >= configuration.maxSessions { guard let evicted = sessions.values - .filter({ $0.state.isTerminal && $0.cleanupConfirmed }) + .filter({ $0.settled && $0.cleanupConfirmed && pendingLaunches[$0.id] == nil }) .min(by: { if $0.updatedAt == $1.updatedAt { return $0.id < $1.id @@ -492,7 +559,6 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { } sessions.removeValue(forKey: evicted.id) } - pendingLaunches += 1 } private func session(named sessionID: String) throws -> Session { @@ -597,6 +663,9 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { if let error = error as? CodexExecRuntimeError { return error } + if error is CancellationError { + return .init(code: "codex.exec.cancelled", message: "Exec launch was cancelled.") + } guard let error = error as? CodexExecError else { return CodexExecRuntimeError( code: "codex.exec.execution_failed", @@ -667,8 +736,8 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { private static func outputCaptureJSON(_ capture: CodexExecOutputCapture) -> JSONValue { .object([ "complete": .bool(capture.isComplete), - "stdout_dropped_bytes": .number(Double(capture.stdoutDroppedBytes)), - "stderr_dropped_bytes": .number(Double(capture.stderrDroppedBytes)), + "stdout_dropped_bytes": .integer(Int64(capture.stdoutDroppedBytes)), + "stderr_dropped_bytes": .integer(Int64(capture.stderrDroppedBytes)), ]) } @@ -678,12 +747,12 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { case .exited(let code): exit = .object([ "kind": .string("exited"), - "code": .number(Double(code)), + "code": .integer(Int64(code)), ]) case .signaled(let signal): exit = .object([ "kind": .string("signaled"), - "signal": .number(Double(signal)), + "signal": .integer(Int64(signal)), ]) } let stderr = outputBounds.text(termination.capturedStderrText) @@ -695,7 +764,7 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { termination.effectiveWorkingDirectory.map { .string($0.path) } ?? .null, "exit": exit, "stderr": .string(stderr.value), - "stderr_original_bytes": .number(Double(stderr.originalBytes)), + "stderr_original_bytes": .integer(Int64(stderr.originalBytes)), "stderr_truncated": .bool(stderr.truncated), ]) } @@ -705,7 +774,7 @@ actor LiveCodexExecRuntime: CodexExecRuntimeProtocol { return .object([ "code": .string(error.code), "message": .string(message.value), - "message_original_bytes": .number(Double(message.originalBytes)), + "message_original_bytes": .integer(Int64(message.originalBytes)), "message_truncated": .bool(message.truncated), ]) } diff --git a/Sources/CodexAdapter/CodexExecutionProvider.swift b/Sources/CodexAdapter/CodexExecutionProvider.swift index 38ba55b..62f6d30 100644 --- a/Sources/CodexAdapter/CodexExecutionProvider.swift +++ b/Sources/CodexAdapter/CodexExecutionProvider.swift @@ -58,6 +58,10 @@ struct CodexExecutionProvider: Sendable { result = try await tryExec().cancel( sessionID: Self.requiredIdentifier("session_id", in: object) ) + case "codex.exec.release": + result = try await tryExec().release( + sessionID: Self.requiredIdentifier("session_id", in: object) + ) default: throw CodexToolError.unknownTool(name) } @@ -208,7 +212,7 @@ struct CodexExecutionProvider: Sendable { ], required: ["prompt"] ), - write: true + risk: .fullShell ), tool( "codex.exec.resume", @@ -222,7 +226,7 @@ struct CodexExecutionProvider: Sendable { ], required: ["upstream_session_id"] ), - write: true + risk: .fullShell ), tool("codex.exec.list", "List gateway-owned Codex Exec sessions.", emptySchema), tool( @@ -239,12 +243,19 @@ struct CodexExecutionProvider: Sendable { "codex.exec.cancel", "Cancel one running Codex Exec session.", objectSchema(properties: ["session_id": stringSchema()], required: ["session_id"]), - write: true + risk: .destructive + ), + tool( + "codex.exec.release", + "Release a retained Exec result after confirmed native cleanup. Native conversation storage is unchanged.", + objectSchema(properties: ["session_id": stringSchema()], required: ["session_id"]), + risk: .destructive ), ] private static func tool( - _ name: String, _ description: String, _ inputSchema: JSONValue, write: Bool = false + _ name: String, _ description: String, _ inputSchema: JSONValue, + risk: CodexOperationRisk = .readOnly ) -> MCP.Tool { let title = name.split(whereSeparator: { $0 == "." || $0 == "_" || $0 == "-" }) .map { String($0.prefix(1)).uppercased() + $0.dropFirst() }.joined(separator: " ") @@ -253,11 +264,14 @@ struct CodexExecutionProvider: Sendable { return .init( name: name, title: title, description: description, inputSchema: input, annotations: .init( - readOnlyHint: !write, destructiveHint: false, idempotentHint: !write, openWorldHint: write), + readOnlyHint: risk == .readOnly, + destructiveHint: risk == .destructive || risk == .fullShell, + idempotentHint: risk == .readOnly, openWorldHint: risk != .readOnly), outputSchema: .object([ "type": .string("object"), "properties": .object(["result": .object([:])]), "required": .array([.string("result")]), "additionalProperties": .bool(false), - ])) + ]), + _meta: .init(additionalFields: ["io.github.computer-mcp/risk": .string(risk.rawValue)])) } private static func objectSchema( @@ -297,10 +311,10 @@ struct CodexExecutionProvider: Sendable { private static func integerSchema(minimum: Int, maximum: Int? = nil) -> JSONValue { var schema: [String: JSONValue] = [ "type": .string("integer"), - "minimum": .number(Double(minimum)), + "minimum": .integer(Int64(minimum)), ] if let maximum { - schema["maximum"] = .number(Double(maximum)) + schema["maximum"] = .integer(Int64(maximum)) } return .object(schema) } diff --git a/Sources/CodexAdapter/CodexHostMCPClient.swift b/Sources/CodexAdapter/CodexHostMCPClient.swift index abb3a4f..981c2d1 100644 --- a/Sources/CodexAdapter/CodexHostMCPClient.swift +++ b/Sources/CodexAdapter/CodexHostMCPClient.swift @@ -1,7 +1,20 @@ -import CryptoKit import Foundation import MCP +#if canImport(CryptoKit) + import CryptoKit +#else + import Crypto +#endif + +#if os(Windows) + typealias CodexHostMCPTransport = MCPInheritedPipeTransport + typealias CodexHostMCPEndpoint = MCPInheritedPipeEndpoint +#else + typealias CodexHostMCPTransport = MCPInheritedSocketTransport + typealias CodexHostMCPEndpoint = FileHandle +#endif + /// Host tools use standard MCP and the existing gateway policy/ticket tool surface. /// Caller-provided tool arguments cannot select this connection or its authority. actor CodexHostMCPClient: CodexHostTools, CodexManagedWorkspaceHost, @@ -11,7 +24,7 @@ actor CodexHostMCPClient: CodexHostTools, CodexManagedWorkspaceHost, private let owner: CodexRuntimeOwner private let workspaceID: String private let client = MCP.Client(name: "codex-host-tools", version: CodexAdapterBuildInfo.version) - private let transport: MCPInheritedSocketTransport + private let transport: CodexHostMCPTransport private let requestTimeout: Duration private var startup: Task? private var connected = false @@ -26,17 +39,24 @@ actor CodexHostMCPClient: CodexHostTools, CodexManagedWorkspaceHost, static func inherited(environment: [String: String], context: CodexLaunchContext) throws -> Self? { - guard let text = environment[descriptorEnvironmentKey] else { return nil } - guard environment["COMPUTER_MCP_HOST_CONTEXT"] != nil, - let descriptor = Int32(text), (3...9).contains(descriptor), String(descriptor) == text - else { throw ConfigurationError.invalid("Invalid inherited host MCP descriptor.") } - return try Self( - takingOwnershipOf: FileHandle(fileDescriptor: descriptor, closeOnDealloc: true), - owner: context.owner) + #if os(Windows) + guard let endpoint = try MCPInheritedPipeEndpoint.inherited(environment: environment) else { + return nil + } + return try Self(takingOwnershipOf: endpoint, owner: context.owner) + #else + guard let text = environment[descriptorEnvironmentKey] else { return nil } + guard environment["COMPUTER_MCP_HOST_CONTEXT"] != nil, + let descriptor = Int32(text), (3...9).contains(descriptor), String(descriptor) == text + else { throw ConfigurationError.invalid("Invalid inherited host MCP descriptor.") } + return try Self( + takingOwnershipOf: FileHandle(fileDescriptor: descriptor, closeOnDealloc: true), + owner: context.owner) + #endif } init( - takingOwnershipOf handle: FileHandle, owner: CodexRuntimeOwner, + takingOwnershipOf handle: CodexHostMCPEndpoint, owner: CodexRuntimeOwner, requestTimeout: Duration = .seconds(30) ) throws { guard requestTimeout > .zero else { @@ -48,7 +68,7 @@ actor CodexHostMCPClient: CodexHostTools, CodexManagedWorkspaceHost, self.owner = owner self.workspaceID = workspaceID self.requestTimeout = requestTimeout - transport = try MCPInheritedSocketTransport(takingOwnershipOf: handle) + transport = try CodexHostMCPTransport(takingOwnershipOf: handle) } func risk(named name: String, arguments: JSONValue, requestID: String, workspaceID: String?) @@ -197,7 +217,7 @@ actor CodexHostMCPClient: CodexHostTools, CodexManagedWorkspaceHost, /// Disconnecting on timeout/cancellation resolves SDK waiters before the task group joins. /// A cancelled deadline after normal completion must not close a reusable connection. private static func bounded( - client: MCP.Client, transport: MCPInheritedSocketTransport, timeout: Duration, + client: MCP.Client, transport: CodexHostMCPTransport, timeout: Duration, operation: @escaping @Sendable () async throws -> T ) async throws -> T { let state = Completion() @@ -240,7 +260,7 @@ extension CodexHostMCPClient { guard (1...1000).contains(limit) else { throw CodexToolError.invalidArguments("Host diagnostic limit must be 1...1000.") } - let value = try await service("host.diagnostics.snapshot", ["limit": .number(Double(limit))]) + let value = try await service("host.diagnostics.snapshot", ["limit": .integer(Int64(limit))]) guard let object = value.objectValue, let returnedOwner = object["owner"], try JSONDecoder().decode(CodexRuntimeOwner.self, from: JSONEncoder().encode(returnedOwner)) diff --git a/Sources/CodexAdapter/CodexLaunchContext.swift b/Sources/CodexAdapter/CodexLaunchContext.swift index d935cdd..249a607 100644 --- a/Sources/CodexAdapter/CodexLaunchContext.swift +++ b/Sources/CodexAdapter/CodexLaunchContext.swift @@ -1,6 +1,11 @@ -import CryptoKit import Foundation +#if canImport(CryptoKit) + import CryptoKit +#else + import Crypto +#endif + /// Host launch metadata binds task ownership; it never grants gateway administration. struct CodexLaunchContext: Sendable { let workspaceURL: URL @@ -14,13 +19,13 @@ struct CodexLaunchContext: Sendable { let host = try? JSONDecoder().decode(Host.self, from: Data(text.utf8)), host.formatVersion == 1, !host.workspace.id.isEmpty, !host.profileID.isEmpty, !host.caller.isEmpty, - host.workspace.rootPath.hasPrefix("/"), !host.workspace.rootPath.contains("\0") + Self.isAbsolutePath(host.workspace.rootPath), !host.workspace.rootPath.contains("\0") else { throw ConfigurationError.invalid("Invalid Computer MCP launch context.") } workspaceURL = URL(fileURLWithPath: host.workspace.rootPath).standardizedFileURL if let root = host.managedWorkspaceRoot { - guard root.hasPrefix("/"), root.utf8.count <= 16_384, !root.contains("\0") else { + guard Self.isAbsolutePath(root), root.utf8.count <= 16_384, !root.contains("\0") else { throw ConfigurationError.invalid("Invalid host-owned managed workspace root.") } managedWorktreeRoot = URL(fileURLWithPath: root, isDirectory: true).standardizedFileURL @@ -89,9 +94,11 @@ struct CodexLaunchContext: Sendable { let leaseScope = SHA256.hash(data: leaseIdentity).map { String(format: "%02x", $0) }.joined() let leaseDirectory = stateDirectory.appendingPathComponent( "worktree-leases", isDirectory: true) - try FileManager.default.createDirectory( - at: leaseDirectory, withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700]) + #if !os(Windows) + try FileManager.default.createDirectory( + at: leaseDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + #endif worktreeLeasePath = leaseDirectory.appendingPathComponent(leaseScope + ".sqlite").path threadOwnerIndex = try makeThreadOwnerIndex(stateDirectory: stateDirectory) } else { @@ -99,9 +106,11 @@ struct CodexLaunchContext: Sendable { worktreeLeasePath = nil threadOwnerIndex = nil } - try FileManager.default.createDirectory( - at: storageDirectory, withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700]) + #if !os(Windows) + try FileManager.default.createDirectory( + at: storageDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + #endif let database = try CodexDatabase( path: storageDirectory.appendingPathComponent("codex.sqlite").path, worktreeLeasePath: worktreeLeasePath) @@ -127,8 +136,11 @@ struct CodexLaunchContext: Sendable { principalID, owner.profileID ?? "", owner.workspaceID ?? "", ]) let scope = SHA256.hash(data: identity).map { String(format: "%02x", $0) }.joined() - try FileManager.default.createDirectory( - at: stateDirectory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + #if !os(Windows) + try FileManager.default.createDirectory( + at: stateDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + #endif let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"].map { URL(fileURLWithPath: $0) } ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex") @@ -137,6 +149,14 @@ struct CodexLaunchContext: Sendable { codexHome: codexHome) } + private static func isAbsolutePath(_ path: String) -> Bool { + #if os(Windows) + WindowsFilePath.isValid(path) && WindowsFilePath.isAbsolute(path) + #else + path.hasPrefix("/") + #endif + } + private struct Host: Decodable { struct Workspace: Decodable { let id: String diff --git a/Sources/CodexAdapter/CodexManagedWorktree.swift b/Sources/CodexAdapter/CodexManagedWorktree.swift index 93eb4d4..4b4cd33 100644 --- a/Sources/CodexAdapter/CodexManagedWorktree.swift +++ b/Sources/CodexAdapter/CodexManagedWorktree.swift @@ -1,7 +1,11 @@ -import CryptoKit -import Darwin import Foundation +#if canImport(CryptoKit) + import CryptoKit +#else + import Crypto +#endif + enum CodexManagedWorktreeState: String, Codable, Equatable, Sendable { case planned case provisioning @@ -99,6 +103,7 @@ enum CodexManagedWorktreeError: Error, LocalizedError, Sendable { } enum CodexManagedWorktreeManager { + private typealias FileSystem = CodexWorktreeFileSystem static func planProvision( database: CodexDatabase?, sourceWorkspaceID: String?, @@ -154,13 +159,15 @@ enum CodexManagedWorktreeManager { ) } } - let sourceRoot = sourceWorkspaceURL.standardizedFileURL.resolvingSymlinksInPath() + let sourceRoot = try canonicalURL(sourceWorkspaceURL.path, relativeTo: sourceWorkspaceURL) let repositoryRoot = try gitPath( ["rev-parse", "--show-toplevel"], workingDirectory: sourceRoot, runner: commandRunner ) - guard canonicalURL(repositoryRoot, relativeTo: sourceRoot).path == sourceRoot.path else { + guard + try FileSystem.sameDirectory(canonicalURL(repositoryRoot, relativeTo: sourceRoot), sourceRoot) + else { throw CodexManagedWorktreeError.invalid( "the registered source workspace must be the Git repository root" ) @@ -189,7 +196,7 @@ enum CodexManagedWorktreeManager { guard isObjectID(headOID) else { throw CodexManagedWorktreeError.command("Git returned an invalid start commit.") } - let commonDirectory = canonicalURL( + let commonDirectory = try canonicalURL( try gitPath( ["rev-parse", "--git-common-dir"], workingDirectory: sourceRoot, @@ -207,10 +214,10 @@ enum CodexManagedWorktreeManager { .appendingPathComponent(sourceComponent, isDirectory: true) .appendingPathComponent(id, isDirectory: true) .standardizedFileURL - guard target.path.hasPrefix(root.standardizedFileURL.path + "/") else { + guard try FileSystem.isDescendant(target, of: root) else { throw CodexManagedWorktreeError.invalid("derived worktree path escaped the managed root") } - guard !FileManager.default.fileExists(atPath: target.path) else { + guard try FileSystem.provisionPathIsAvailable(target) else { throw CodexManagedWorktreeError.invalid("derived worktree path already exists") } guard @@ -295,10 +302,10 @@ enum CodexManagedWorktreeManager { let sourceRoot = URL(fileURLWithPath: record.sourceRepositoryRoot, isDirectory: true) let expectedRoot = managedRootURL(database: database, override: managedRoot) let target = URL(fileURLWithPath: record.path, isDirectory: true).standardizedFileURL - guard target.path.hasPrefix(expectedRoot.standardizedFileURL.path + "/") else { + guard try FileSystem.isDescendant(target, of: expectedRoot) else { throw CodexManagedWorktreeError.invalid("persisted path is outside the managed root") } - guard !FileManager.default.fileExists(atPath: target.path) else { + guard try FileSystem.provisionPathIsAvailable(target) else { throw CodexManagedWorktreeError.state("the planned worktree path already exists") } let resolvedOID = try gitText( @@ -328,24 +335,19 @@ enum CodexManagedWorktreeManager { } var createdLease: CodexWorktreeLease? var createdGitWorktree = false + var protections: [FileSystem.Protection] = [] + defer { withExtendedLifetime(protections) {} } do { - try prepareManagedRoot(expectedRoot) - try FileManager.default.createDirectory( - at: target.deletingLastPathComponent(), - withIntermediateDirectories: true, - attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] - ) - try validateManagedDirectory( - target.deletingLastPathComponent(), - containedIn: expectedRoot - ) + protections.append(try FileSystem.prepareRoot(expectedRoot)) + protections.append( + try FileSystem.prepareParent(target.deletingLastPathComponent(), containedIn: expectedRoot)) _ = try git( ["worktree", "add", "-b", record.branch, target.path, record.headOID], workingDirectory: sourceRoot, runner: commandRunner ) createdGitWorktree = true - let actualRoot = canonicalURL( + let actualRoot = try canonicalURL( try gitPath( ["rev-parse", "--show-toplevel"], workingDirectory: target, @@ -353,7 +355,7 @@ enum CodexManagedWorktreeManager { ), relativeTo: target ) - let actualCommon = canonicalURL( + let actualCommon = try canonicalURL( try gitPath( ["rev-parse", "--git-common-dir"], workingDirectory: target, @@ -362,8 +364,8 @@ enum CodexManagedWorktreeManager { relativeTo: target ) try validateManagedDirectory(target, containedIn: expectedRoot) - guard actualRoot.path == target.resolvingSymlinksInPath().path, - actualCommon.path == record.gitCommonDirectory + guard try FileSystem.sameDirectory(actualRoot, target.resolvingSymlinksInPath()), + try FileSystem.sameDirectory(actualCommon, URL(fileURLWithPath: record.gitCommonDirectory)) else { throw CodexManagedWorktreeError.state( "Git did not create the exact planned worktree in the source repository" @@ -424,8 +426,7 @@ enum CodexManagedWorktreeManager { _ = try git( ["worktree", "remove", target.path], workingDirectory: sourceRoot, runner: commandRunner ) - var info = stat() - guard lstat(target.path, &info) != 0, errno == ENOENT else { + guard try FileSystem.isAbsent(target) else { throw CodexManagedWorktreeError.state("Git rollback did not remove the exact target") } // Delete only the exact branch value created by this operation; an independently @@ -473,6 +474,12 @@ enum CodexManagedWorktreeManager { else { throw CodexManagedWorktreeError.unknown(managedWorktreeID) } + #if os(Windows) + let protection = try FileSystem.protectDirectory( + URL(fileURLWithPath: record.path).deletingLastPathComponent(), + containedIn: managedRootURL(database: database, override: managedRoot)) + defer { withExtendedLifetime(protection) {} } + #endif if record.state == .removing { try validateRemovedWorktree( record, database: database, liveRuntimeStatus: liveRuntimeStatus, @@ -547,6 +554,12 @@ enum CodexManagedWorktreeManager { cleanupOnly || (record.state == .removalPlanned && record.planExpiresAt.map({ $0 > now }) == true) else { throw CodexManagedWorktreeError.state("the removal plan is not current") } + #if os(Windows) + let protection = try FileSystem.protectDirectory( + URL(fileURLWithPath: record.path).deletingLastPathComponent(), + containedIn: managedRootURL(database: database, override: managedRoot)) + defer { withExtendedLifetime(protection) {} } + #endif if cleanupOnly { try validateRemovedWorktree( record, database: database, liveRuntimeStatus: liveRuntimeStatus, @@ -647,7 +660,7 @@ enum CodexManagedWorktreeManager { let root = managedRootURL(database: database, override: managedRoot).standardizedFileURL let target = URL(fileURLWithPath: record.path, isDirectory: true).standardizedFileURL try validateManagedDirectory(root, containedIn: root) - guard target.path.hasPrefix(root.path + "/"), + guard try FileSystem.isDescendant(target, of: root), FileManager.default.fileExists(atPath: target.path) else { throw CodexManagedWorktreeError.state( @@ -655,7 +668,7 @@ enum CodexManagedWorktreeManager { ) } try validateManagedDirectory(target, containedIn: root) - let actualRoot = canonicalURL( + let actualRoot = try canonicalURL( try gitPath( ["rev-parse", "--show-toplevel"], workingDirectory: target, @@ -663,7 +676,7 @@ enum CodexManagedWorktreeManager { ), relativeTo: target ) - let actualCommon = canonicalURL( + let actualCommon = try canonicalURL( try gitPath( ["rev-parse", "--git-common-dir"], workingDirectory: target, @@ -671,8 +684,8 @@ enum CodexManagedWorktreeManager { ), relativeTo: target ) - guard actualRoot.path == target.resolvingSymlinksInPath().path, - actualCommon.path == record.gitCommonDirectory + guard try FileSystem.sameDirectory(actualRoot, target.resolvingSymlinksInPath()), + try FileSystem.sameDirectory(actualCommon, URL(fileURLWithPath: record.gitCommonDirectory)) else { throw CodexManagedWorktreeError.state( "the path is not the exact worktree recorded by Computer MCP" @@ -712,22 +725,22 @@ enum CodexManagedWorktreeManager { } let root = managedRootURL(database: database, override: managedRoot).standardizedFileURL let target = URL(fileURLWithPath: record.path).standardizedFileURL - guard target.path.hasPrefix(root.path + "/") else { + guard try FileSystem.isDescendant(target, of: root) else { throw CodexManagedWorktreeError.state("metadata cleanup path escaped the managed root") } try validateManagedDirectory(root, containedIn: root) try validateManagedDirectory(target.deletingLastPathComponent(), containedIn: root) - var info = stat() - guard lstat(target.path, &info) != 0, errno == ENOENT else { + guard try FileSystem.isAbsent(target) else { throw CodexManagedWorktreeError.state( "metadata cleanup refuses an existing or unverified path") } let source = URL(fileURLWithPath: record.sourceRepositoryRoot) - let common = canonicalURL( + let common = try canonicalURL( try gitPath( ["rev-parse", "--git-common-dir"], workingDirectory: source, runner: commandRunner), relativeTo: source) - guard common.path == record.gitCommonDirectory else { + guard try FileSystem.sameDirectory(common, URL(fileURLWithPath: record.gitCommonDirectory)) + else { throw CodexManagedWorktreeError.state("metadata cleanup source repository identity changed") } let inventory = try git( @@ -737,58 +750,15 @@ enum CodexManagedWorktreeManager { return String(entry.dropFirst("worktree ".count)) } guard - !paths.contains(where: { URL(fileURLWithPath: $0).standardizedFileURL.path == target.path }) + try !paths.contains(where: { try FileSystem.samePath(URL(fileURLWithPath: $0), target) }) else { throw CodexManagedWorktreeError.state( "Git still records the worktree; metadata-only cleanup is unsafe") } } - private static func prepareManagedRoot(_ root: URL) throws { - let fileManager = FileManager.default - if fileManager.fileExists(atPath: root.path) { - let values = try root.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) - guard values.isDirectory == true, values.isSymbolicLink != true else { - throw CodexManagedWorktreeError.invalid( - "the managed worktree root must be a real directory" - ) - } - let attributes = try fileManager.attributesOfItem(atPath: root.path) - if let owner = attributes[.ownerAccountID] as? NSNumber, owner.uint32Value != getuid() { - throw CodexManagedWorktreeError.invalid( - "the managed worktree root is owned by another user" - ) - } - } else { - try fileManager.createDirectory( - at: root, - withIntermediateDirectories: true, - attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] - ) - } - try fileManager.setAttributes( - [.posixPermissions: NSNumber(value: Int16(0o700))], - ofItemAtPath: root.path - ) - } - private static func validateManagedDirectory(_ directory: URL, containedIn root: URL) throws { - let values = try directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) - guard values.isDirectory == true, values.isSymbolicLink != true else { - throw CodexManagedWorktreeError.invalid( - "managed worktree paths must be real directories, not symbolic links" - ) - } - let resolvedDirectory = directory.standardizedFileURL.resolvingSymlinksInPath() - let resolvedRoot = root.standardizedFileURL.resolvingSymlinksInPath() - guard - resolvedDirectory == resolvedRoot - || resolvedDirectory.path.hasPrefix(resolvedRoot.path + "/") - else { - throw CodexManagedWorktreeError.invalid( - "managed worktree path escaped the canonical managed root" - ) - } + try FileSystem.validateDirectory(directory, containedIn: root) } private static func validateText(_ value: String, name: String, maximum: Int) throws { @@ -855,7 +825,7 @@ enum CodexManagedWorktreeManager { runner: any CommandRunning ) throws -> CommandResult { try runner.run( - executable: "/usr/bin/git", + executable: FileSystem.gitExecutable, arguments: arguments, workingDirectory: workingDirectory, environment: ["GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"], @@ -872,12 +842,8 @@ enum CodexManagedWorktreeManager { return .command(CodexApprovalRedactor.redactString(detail, maximumCharacters: 4_096)) } - private static func canonicalURL(_ path: String, relativeTo base: URL) -> URL { - let url = - path.hasPrefix("/") - ? URL(fileURLWithPath: path, isDirectory: true) - : base.appendingPathComponent(path, isDirectory: true) - return url.standardizedFileURL.resolvingSymlinksInPath() + private static func canonicalURL(_ path: String, relativeTo base: URL) throws -> URL { + try FileSystem.canonicalDirectory(path, relativeTo: base) } private static func isObjectID(_ value: String) -> Bool { diff --git a/Sources/CodexAdapter/CodexNativeResources.swift b/Sources/CodexAdapter/CodexNativeResources.swift new file mode 100644 index 0000000..83c09cf --- /dev/null +++ b/Sources/CodexAdapter/CodexNativeResources.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Native handles belong to one App Server connection, even when their RPC has returned. +struct CodexNativeResources: Sendable { + struct Key: Hashable, Sendable { + let kind: String + let id: String + } + struct Ticket: Sendable { + let key: Key + let generation: Int + let token: UUID + let starts: Bool + let ends: Bool + } + private struct Entry: Sendable { + let generation: Int + let token: UUID + let workInvocation: UUID? + var state: CodexWorkResource.State = .active + } + private var entries: [Key: Entry] = [:] + var count: Int { entries.count } + + mutating func prepare(method: String, params: JSONValue?, generation: Int) throws -> Ticket? { + let kind: String + let field: String + let starts: Bool + let ends: Bool + switch method { + case "command/exec": (kind, field, starts, ends) = ("command", "processId", true, true) + case "command/exec/write", "command/exec/resize": + (kind, field, starts, ends) = ("command", "processId", false, false) + case "command/exec/terminate": + (kind, field, starts, ends) = ("command", "processId", false, false) + case "process/spawn": (kind, field, starts, ends) = ("process", "processHandle", true, false) + case "process/writeStdin", "process/resizePty", "process/kill": + (kind, field, starts, ends) = ("process", "processHandle", false, false) + case "fs/watch": (kind, field, starts, ends) = ("watch", "watchId", true, false) + case "fs/unwatch": (kind, field, starts, ends) = ("watch", "watchId", false, true) + case "mcpServer/event/stream/start": + (kind, field, starts, ends) = ("stream", "subscriptionId", true, false) + case "mcpServer/event/stream/stop": + (kind, field, starts, ends) = ("stream", "subscriptionId", false, true) + default: return nil + } + guard let id = params?.objectValue?[field]?.stringValue else { + // A synchronous command need not expose an interactive handle. + if method == "command/exec" { return nil } + throw CodexToolError.invalidArguments("Native request requires \(field).") + } + guard !id.isEmpty, id.utf8.count <= 1_024, + id.rangeOfCharacter(from: .controlCharacters) == nil + else { throw CodexToolError.invalidArguments("Native resource handle is invalid.") } + let key = Key(kind: kind, id: id) + if starts { + guard entries[key] == nil, entries.count < 256 else { + throw CodexToolError.disabled( + "codex.app.resource_busy: Native handle is reserved or resource capacity is reached.") + } + let token = UUID() + entries[key] = Entry( + generation: generation, token: token, workInvocation: CodexWorkInvocation.current) + return Ticket(key: key, generation: generation, token: token, starts: true, ends: ends) + } + guard let entry = entries[key], entry.generation == generation else { + throw CodexToolError.disabled( + "codex.app.resource_unknown: Native handle is not owned by this connection generation.") + } + return Ticket(key: key, generation: generation, token: entry.token, starts: false, ends: ends) + } + + mutating func completed(_ ticket: Ticket?, rejected: Bool = false) { + guard let ticket, rejected ? ticket.starts : ticket.ends, + let entry = entries[ticket.key], entry.generation == ticket.generation, + entry.token == ticket.token + else { return } + entries.removeValue(forKey: ticket.key) + } + + mutating func uncertain(_ ticket: Ticket?) { + guard let ticket, let entry = entries[ticket.key], entry.generation == ticket.generation, + entry.token == ticket.token + else { return } + entries[ticket.key]?.state = .uncertain + } + + func workResources() throws -> [CodexWorkResource] { + try entries.map { key, entry in + // Callers may reuse a native handle after release; the reservation token + // identifies this ownership lifetime across complete work snapshots. + try CodexWorkResource( + kind: "codex.app.\(key.kind)", id: entry.token.uuidString.lowercased(), + acquiredBy: entry.workInvocation, + state: entry.state, handles: ["native_id": .string(key.id)]) + }.sorted { ($0.kind, $0.id) < ($1.kind, $1.id) } + } + + mutating func processExited(handle: String, generation: Int) { + let key = Key(kind: "process", id: handle) + if entries[key]?.generation == generation { entries.removeValue(forKey: key) } + } + + mutating func retired(generation: Int) { + entries = entries.filter { $0.value.generation != generation } + } +} diff --git a/Sources/CodexAdapter/CodexOperationalDiagnostics.swift b/Sources/CodexAdapter/CodexOperationalDiagnostics.swift index 57164e3..0272957 100644 --- a/Sources/CodexAdapter/CodexOperationalDiagnostics.swift +++ b/Sources/CodexAdapter/CodexOperationalDiagnostics.swift @@ -84,17 +84,17 @@ enum CodexOperationalDiagnostics { ]), "host_diagnostics_available": .bool(hostSnapshot != nil), "summary": .object([ - "live_runtime_count": .number(Double(liveRuntimeCount(liveRuntimes))), - "persisted_runtime_count": .number(Double(persistedRuntimes.count)), - "thread_ownership_receipt_count": .number(Double(threadOwnership.count)), - "pending_approval_count": .number(Double(pendingApprovals.count)), + "live_runtime_count": .integer(Int64(liveRuntimeCount(liveRuntimes))), + "persisted_runtime_count": .integer(Int64(persistedRuntimes.count)), + "thread_ownership_receipt_count": .integer(Int64(threadOwnership.count)), + "pending_approval_count": .integer(Int64(pendingApprovals.count)), "ownership_reconciliation_candidate_count": .number( Double(ownershipReconciliation.candidates.count) ), - "active_run_count": .number(Double(activeRuns.count)), - "active_worktree_lease_count": .number(Double(activeLeases.count)), - "active_managed_worktree_count": .number(Double(activeManagedWorktrees.count)), - "finding_count": .number(Double(findings.count)), + "active_run_count": .integer(Int64(activeRuns.count)), + "active_worktree_lease_count": .integer(Int64(activeLeases.count)), + "active_managed_worktree_count": .integer(Int64(activeManagedWorktrees.count)), + "finding_count": .integer(Int64(findings.count)), ]), "findings": .array(findings), "live_runtimes": liveRuntimes, @@ -282,7 +282,10 @@ enum CodexOperationalDiagnostics { result["category"] = .string( object["capability_id"]?.stringValue?.hasPrefix("git.") == true ? "git" : "tool") for key in ["duration_milliseconds", "output_byte_count"] { - result[key] = object[key]?.numberValue.map(JSONValue.number) ?? .null + switch object[key] { + case .integer, .number: result[key] = object[key] + default: result[key] = .null + } } result["output_truncated"] = object["output_truncated"]?.boolValue.map(JSONValue.bool) ?? .null return .object(result) diff --git a/Sources/CodexAdapter/CodexOrchestration.swift b/Sources/CodexAdapter/CodexOrchestration.swift index 1796a3c..20c57c0 100644 --- a/Sources/CodexAdapter/CodexOrchestration.swift +++ b/Sources/CodexAdapter/CodexOrchestration.swift @@ -1,6 +1,11 @@ -import CryptoKit import Foundation +#if canImport(CryptoKit) + import CryptoKit +#else + import Crypto +#endif + enum CodexOrchestrationRunState: String, Codable, Equatable, Sendable { case active case paused diff --git a/Sources/CodexAdapter/CodexProcessEnvironment.swift b/Sources/CodexAdapter/CodexProcessEnvironment.swift index caade9a..5244311 100644 --- a/Sources/CodexAdapter/CodexProcessEnvironment.swift +++ b/Sources/CodexAdapter/CodexProcessEnvironment.swift @@ -1,5 +1,8 @@ import Foundation -import SystemConfiguration + +#if canImport(SystemConfiguration) + import SystemConfiguration +#endif internal struct SystemNetworkProxySettings: Equatable, Sendable { internal var httpProxy: String? @@ -88,6 +91,8 @@ internal enum CodexProcessEnvironment { private static let parentSessionKeys = [ "COMPUTER_MCP_HOST_CONTEXT", "COMPUTER_MCP_HOST_FD", + "COMPUTER_MCP_HOST_READ_HANDLE", + "COMPUTER_MCP_HOST_WRITE_HANDLE", "CODEX_APP_TOOLS_PIPE_PATH", "CODEX_CI", "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", @@ -102,11 +107,12 @@ internal enum CodexProcessEnvironment { systemProxy: SystemNetworkProxySettings = .current() ) -> [String: String] { var environment = base - for key in parentSessionKeys { + for key in base.keys where parentSessionKeys.contains(where: { namesMatch(key, $0) }) { environment.removeValue(forKey: key) } - let hasInheritedProxy = (httpKeys + httpsKeys + allKeys).contains { - environment[$0] != nil + let proxyKeys = httpKeys + httpsKeys + allKeys + let hasInheritedProxy = environment.keys.contains { key in + proxyKeys.contains { namesMatch(key, $0) } } if hasInheritedProxy { @@ -119,11 +125,11 @@ internal enum CodexProcessEnvironment { install(systemProxy.socksProxy, for: allKeys, in: &environment) } - let hasEffectiveProxy = (httpKeys + httpsKeys + allKeys).contains { - !(environment[$0] ?? "").isEmpty + let hasEffectiveProxy = environment.contains { key, value in + !value.isEmpty && proxyKeys.contains { namesMatch(key, $0) } } if hasEffectiveProxy { - if noProxyKeys.contains(where: { environment[$0] != nil }) { + if environment.keys.contains(where: { key in noProxyKeys.contains { namesMatch(key, $0) } }) { mirrorExistingValue(for: noProxyKeys, in: &environment) } else { let noProxy = normalizedBypassHosts(systemProxy.bypassHosts).joined(separator: ",") @@ -137,13 +143,15 @@ internal enum CodexProcessEnvironment { for keys: [String], in environment: inout [String: String] ) { - let existingValues = keys.compactMap { key in - environment[key].map { (key: key, value: $0) } - } - guard existingValues.count == 1, let existingValue = existingValues.first?.value else { - return - } - install(existingValue, for: keys, in: &environment) + #if !os(Windows) + let existingValues = keys.compactMap { key in + environment[key].map { (key: key, value: $0) } + } + guard existingValues.count == 1, let existingValue = existingValues.first?.value else { + return + } + install(existingValue, for: keys, in: &environment) + #endif } private static func install( @@ -152,9 +160,24 @@ internal enum CodexProcessEnvironment { in environment: inout [String: String] ) { guard let value else { return } - for key in keys { - environment[key] = value - } + #if os(Windows) + // Windows already resolves names case-insensitively; aliases would be duplicates. + for key in keys.prefix(1) { + environment[key] = value + } + #else + for key in keys { + environment[key] = value + } + #endif + } + + static func namesMatch(_ lhs: String, _ rhs: String) -> Bool { + #if os(Windows) + return WindowsProcessEnvironment.namesMatch(lhs, rhs) + #else + return lhs == rhs + #endif } private static func normalizedBypassHosts(_ hosts: [String]) -> [String] { diff --git a/Sources/CodexAdapter/CodexQueuedWork.swift b/Sources/CodexAdapter/CodexQueuedWork.swift new file mode 100644 index 0000000..5edbae5 --- /dev/null +++ b/Sources/CodexAdapter/CodexQueuedWork.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Queued user messages can start a turn before enqueue returns its durable ID. +struct CodexQueuedWork: Sendable { + struct Ticket: Sendable { + let threadID: String + let clientID: String + let workID: String + } + private struct Key: Hashable, Sendable { + let threadID: String + let clientID: String + } + private struct Entry: Sendable { + let generation: Int + let binding: CodexWorkBinding + var submissionID: String? + var turnID: String? + var awaitingReply = true + var completed = false + var state: CodexWorkResource.State = .active + } + private var entries: [Key: Entry] = [:] + var isEmpty: Bool { entries.isEmpty } + + func hasPending(threadID: String) -> Bool { + entries.contains { $0.key.threadID == threadID && $0.value.turnID == nil } + } + + mutating func prepare(method: String, params: JSONValue?, generation: Int) throws -> Ticket? { + guard method == "thread/queue/add", + let threadID = params?.objectValue?["threadId"]?.stringValue, + let clientID = params?.objectValue?["clientUserMessageId"]?.stringValue + else { return nil } + let key = Key(threadID: threadID, clientID: clientID) + guard entries[key] == nil, entries.count < 256, clientID.utf8.count <= 1_024 else { + throw CodexToolError.disabled( + "codex.app.queue_work_busy: The client message identity is still owned or queue capacity is reached." + ) + } + let binding = CodexWorkBinding(origin: .init(invocation: CodexWorkInvocation.current)) + entries[key] = .init(generation: generation, binding: binding) + return .init(threadID: threadID, clientID: clientID, workID: binding.id) + } + + mutating func replied(_ ticket: Ticket?, response: JSONValue) { + guard let ticket else { return } + let key = Key(threadID: ticket.threadID, clientID: ticket.clientID) + guard var entry = entries[key], entry.binding.id == ticket.workID else { return } + entry.awaitingReply = false + entry.submissionID = response.objectValue?["queuedSubmission"]?.objectValue?["id"]?.stringValue + if entry.completed { entries.removeValue(forKey: key) } else { entries[key] = entry } + } + + mutating func failed(_ ticket: Ticket?, rejected: Bool) { + guard let ticket else { return } + let key = Key(threadID: ticket.threadID, clientID: ticket.clientID) + guard entries[key]?.binding.id == ticket.workID else { return } + if rejected && entries[key]?.turnID == nil { + entries.removeValue(forKey: key) + } else { + entries[key]?.state = .uncertain + } + } + + mutating func consumed( + threadID: String, clientID: String, turnID: String, generation: Int + ) -> CodexWorkOrigin? { + let key = Key(threadID: threadID, clientID: clientID) + guard let entry = entries[key], entry.generation == generation, + entry.turnID == nil || entry.turnID == turnID + else { return nil } + entries[key]?.turnID = turnID + return entry.binding.origin + } + + func origin(threadID: String, submissionID: String?) -> CodexWorkOrigin? { + guard let submissionID else { return nil } + return entries.first { + $0.key.threadID == threadID && $0.value.submissionID == submissionID + }?.value.binding.origin + } + + mutating func completed(threadID: String, turnID: String, generation: Int) { + for key in entries.keys where key.threadID == threadID { + guard let entry = entries[key], entry.generation == generation, entry.turnID == turnID else { + continue + } + if entry.awaitingReply { + entries[key]?.completed = true + } else { + entries.removeValue(forKey: key) + } + } + } + + mutating func deleted(method: String, params: JSONValue?, response: JSONValue, generation: Int) { + guard method == "thread/queue/delete", response.objectValue?["deleted"] == .bool(true), + let threadID = params?.objectValue?["threadId"]?.stringValue, + let submissionID = params?.objectValue?["queuedSubmissionId"]?.stringValue + else { return } + entries = entries.filter { + $0.key.threadID != threadID || $0.value.generation != generation + || $0.value.submissionID != submissionID || $0.value.turnID != nil + } + } + + func workResources() throws -> [CodexWorkResource] { + try entries.filter { $0.value.turnID == nil }.map { key, entry in + var handles: [String: JSONValue] = [ + "thread_id": .string(key.threadID), "client_id": .string(key.clientID), + ] + if let id = entry.submissionID { handles["submission_id"] = .string(id) } + return try entry.binding.resource( + "codex.app.queued-input", state: entry.state, handles: handles) + } + } + + mutating func retired(generation: Int) { + entries = entries.filter { $0.value.generation != generation } + } +} diff --git a/Sources/CodexAdapter/CodexRecentThreadReader.swift b/Sources/CodexAdapter/CodexRecentThreadReader.swift index 4b052b8..f91af08 100644 --- a/Sources/CodexAdapter/CodexRecentThreadReader.swift +++ b/Sources/CodexAdapter/CodexRecentThreadReader.swift @@ -1,7 +1,12 @@ -import CryptoKit import Foundation import GRDB +#if canImport(CryptoKit) + import CryptoKit +#else + import Crypto +#endif + struct CodexPersistedThreadMetadata: Equatable, Sendable { let id: String let rolloutURL: URL @@ -169,9 +174,9 @@ struct CodexRecentThreadReader: Sendable { "id": .string(metadata.id), "cwd": .string(metadata.cwd), "title": .string(Self.bounded(metadata.title, maximumCharacters: 2_048)), - "created_at_seconds": .number(Double(metadata.createdAtSeconds)), - "updated_at_seconds": .number(Double(metadata.updatedAtSeconds)), - "tokens_used": .number(Double(metadata.tokensUsed)), + "created_at_seconds": .integer(Int64(metadata.createdAtSeconds)), + "updated_at_seconds": .integer(Int64(metadata.updatedAtSeconds)), + "tokens_used": .integer(Int64(metadata.tokensUsed)), "archived": .bool(metadata.archived), "first_user_message": .string( Self.bounded( @@ -188,7 +193,7 @@ struct CodexRecentThreadReader: Sendable { maximumCharacters: 8_192 ) ), - "rollout_size_bytes": .number(Double(rollout.size)), + "rollout_size_bytes": try .encoded(rollout.size), ]) let result: JSONValue = .object([ "schema_version": .number(1), @@ -204,17 +209,17 @@ struct CodexRecentThreadReader: Sendable { "next_before_cursor": nextCursor.map(JSONValue.string) ?? .null, "has_more": .bool(nextCursor != nil), "bounds": .object([ - "page_bytes_read": .number(Double(page.bytesRead)), - "goal_scan_bytes_read": .number(Double(goalScan.bytesRead)), - "total_io_bytes_read": .number(Double(page.bytesRead + goalScan.bytesRead)), - "records_decoded": .number(Double(decoded.count)), - "max_turns": .number(Double(limits.maxTurns)), - "max_messages": .number(Double(limits.maxMessages)), - "max_items": .number(Double(limits.maxItems)), - "max_page_bytes": .number(Double(limits.maxReadBytes)), - "max_goal_scan_bytes": .number(Double(CodexRecentThreadLimits.maximumGoalScanBytes)), - "max_output_bytes": .number(Double(limits.maxOutputBytes)), - "max_elapsed_milliseconds": .number(Double(limits.maxElapsedMilliseconds)), + "page_bytes_read": .integer(Int64(page.bytesRead)), + "goal_scan_bytes_read": .integer(Int64(goalScan.bytesRead)), + "total_io_bytes_read": .integer(Int64(page.bytesRead + goalScan.bytesRead)), + "records_decoded": .integer(Int64(decoded.count)), + "max_turns": .integer(Int64(limits.maxTurns)), + "max_messages": .integer(Int64(limits.maxMessages)), + "max_items": .integer(Int64(limits.maxItems)), + "max_page_bytes": .integer(Int64(limits.maxReadBytes)), + "max_goal_scan_bytes": .integer(Int64(CodexRecentThreadLimits.maximumGoalScanBytes)), + "max_output_bytes": .integer(Int64(limits.maxOutputBytes)), + "max_elapsed_milliseconds": .integer(Int64(limits.maxElapsedMilliseconds)), "latency_budget_exhausted": .bool(goalScan.latencyBudgetExhausted), "elapsed_milliseconds": .number(elapsedMilliseconds), ]), @@ -497,7 +502,7 @@ struct CodexRecentThreadReader: Sendable { ) ), "timestamp": record.timestamp.map(JSONValue.string) ?? .null, - "ordinal": record.ordinal.map { .number(Double($0)) } ?? .null, + "ordinal": record.ordinal.map { .integer(Int64($0)) } ?? .null, ]) messages.append(message) if role == "assistant" { latestAssistantProgress = message } @@ -524,7 +529,7 @@ struct CodexRecentThreadReader: Sendable { ) } ?? .null, "timestamp": record.timestamp.map(JSONValue.string) ?? .null, - "ordinal": record.ordinal.map { .number(Double($0)) } ?? .null, + "ordinal": record.ordinal.map { .integer(Int64($0)) } ?? .null, ]) ) } diff --git a/Sources/CodexAdapter/CodexRemoteControlWork.swift b/Sources/CodexAdapter/CodexRemoteControlWork.swift new file mode 100644 index 0000000..fd5122c --- /dev/null +++ b/Sources/CodexAdapter/CodexRemoteControlWork.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Native remote-control status describes desired connectivity, not joined cleanup. +struct CodexRemoteControlWork: Sendable { + private struct Entry: Sendable { + let generation: Int + let binding: CodexWorkBinding + var initialStatusPending: Bool + var enabled = false + var state: CodexWorkResource.State = .uncertain + } + + private var entry: Entry? + var isEmpty: Bool { entry == nil } + + mutating func started(generation: Int, origin: CodexWorkOrigin) { + entry = .init( + generation: generation, binding: .init(origin: origin), initialStatusPending: true) + } + + mutating func prepare(method: String, generation: Int) { + if method == "remoteControl/disable" { + entry?.state = .uncertain + return + } + guard method == "remoteControl/enable" || method == "remoteControl/pairing/start" else { + return + } + if entry == nil || entry?.initialStatusPending == true { + entry = .init( + generation: generation, + binding: .init(origin: .init(invocation: CodexWorkInvocation.current)), + initialStatusPending: false) + } + entry?.enabled = true + } + + mutating func notified(params: [String: JSONValue], generation: Int) { + guard let status = params["status"]?.stringValue else { return } + if status == "disabled" { + if entry?.initialStatusPending == true, entry?.enabled == false { + entry = nil + } else { + entry?.state = .uncertain + } + return + } + if entry == nil { + // Another native client can enable the same process. Observing its status + // cannot grant an adapter invocation ownership of that external action. + entry = .init(generation: generation, binding: .init(), initialStatusPending: false) + } + entry?.initialStatusPending = false + entry?.enabled = true + entry?.state = status == "connected" || status == "connecting" ? .active : .uncertain + } + + func workResources() throws -> [CodexWorkResource] { + guard let entry else { return [] } + return [try entry.binding.resource("codex.app.remote-control", state: entry.state)] + } + + mutating func retired(generation: Int) { + if entry?.generation == generation { entry = nil } + } +} diff --git a/Sources/CodexAdapter/CodexRuntimeLease.swift b/Sources/CodexAdapter/CodexRuntimeLease.swift index 72ee12e..a580702 100644 --- a/Sources/CodexAdapter/CodexRuntimeLease.swift +++ b/Sources/CodexAdapter/CodexRuntimeLease.swift @@ -1,6 +1,11 @@ -import Darwin import Foundation +#if os(Windows) + import WinSDK +#else + import Darwin +#endif + struct CodexRuntimeRequestFailure: Codable, Equatable, Sendable { let kind: String let message: String @@ -94,8 +99,8 @@ enum CodexRuntimeMaintenance { var pending: [JSONValue] = [] for var record in try visibleRecords(database: database, workspaceID: workspaceID) { guard CodexRuntimeDirectory.shared.runtime(id: record.id) == nil else { continue } - if processExists(record.process?.processID) - || processExists(record.process?.supervisorProcessID) + if codexRuntimeProcessMayExist(record.process?.processID) + || codexRuntimeProcessMayExist(record.process?.supervisorProcessID) { pending.append(candidate(record)) continue @@ -133,8 +138,8 @@ enum CodexRuntimeMaintenance { private static func candidate(_ record: CodexRuntimeLeaseRecord) -> JSONValue { let directoryActive = CodexRuntimeDirectory.shared.runtime(id: record.id) != nil let processActive = - processExists(record.process?.processID) - || processExists(record.process?.supervisorProcessID) + codexRuntimeProcessMayExist(record.process?.processID) + || codexRuntimeProcessMayExist(record.process?.supervisorProcessID) return .object([ "runtime": record.json, "directory_active": .bool(directoryActive), @@ -146,9 +151,21 @@ enum CodexRuntimeMaintenance { ]) } - private static func processExists(_ processID: Int32?) -> Bool { +} + +/// Read-only evidence for persisted receipts. Unknown access and reused IDs retain ownership; +/// a numeric ID never grants authority to signal the process or confirm descendant cleanup. +func codexRuntimeProcessMayExist(_ processID: Int32?) -> Bool { + #if os(Windows) + guard let processID, DWORD(bitPattern: processID) > 1 else { return false } + guard let handle = OpenProcess(DWORD(SYNCHRONIZE), false, DWORD(bitPattern: processID)) else { + return GetLastError() != DWORD(ERROR_INVALID_PARAMETER) + } + defer { CloseHandle(handle) } + return WaitForSingleObject(handle, 0) != DWORD(WAIT_OBJECT_0) + #else guard let processID, processID > 1 else { return false } errno = 0 return Darwin.kill(processID, 0) == 0 || errno != ESRCH - } + #endif } diff --git a/Sources/CodexAdapter/CodexStateMigration.swift b/Sources/CodexAdapter/CodexStateMigration.swift index eb8cd7c..d54c1f2 100644 --- a/Sources/CodexAdapter/CodexStateMigration.swift +++ b/Sources/CodexAdapter/CodexStateMigration.swift @@ -1,7 +1,12 @@ -import CryptoKit import Foundation import GRDB +#if canImport(CryptoKit) + import CryptoKit +#else + import Crypto +#endif + /// Transfers only adapter-owned domain records. This is an explicit offline /// administration operation, not an MCP tool or a host-authority channel. enum CodexStateMigration { diff --git a/Sources/CodexAdapter/CodexThreadOwnerIndex.swift b/Sources/CodexAdapter/CodexThreadOwnerIndex.swift index 6b7ccc8..3c7cdc7 100644 --- a/Sources/CodexAdapter/CodexThreadOwnerIndex.swift +++ b/Sources/CodexAdapter/CodexThreadOwnerIndex.swift @@ -1,7 +1,12 @@ -import CryptoKit import Foundation import GRDB +#if canImport(CryptoKit) + import CryptoKit +#else + import Crypto +#endif + /// Cross-subject affinity for native threads; all execution records remain in their subject database. final class CodexThreadOwnerIndex: @unchecked Sendable { private let database: DatabaseQueue @@ -16,6 +21,12 @@ final class CodexThreadOwnerIndex: @unchecked Sendable { .map { String(format: "%02x", $0) }.joined() var configuration = Configuration() configuration.busyMode = .timeout(5) + #if os(Windows) + let directory = try WindowsPrivateDirectory( + URL(fileURLWithPath: path).deletingLastPathComponent()) + // The connection owns this validation closure and its directory handles through close. + configuration.prepareDatabase { [directory] _ in try directory.validate() } + #endif database = try DatabaseQueue(path: path, configuration: configuration) try database.write { db in try db.execute( diff --git a/Sources/CodexAdapter/CodexThreadOwnership.swift b/Sources/CodexAdapter/CodexThreadOwnership.swift index f3f385e..bc69e18 100644 --- a/Sources/CodexAdapter/CodexThreadOwnership.swift +++ b/Sources/CodexAdapter/CodexThreadOwnership.swift @@ -4,6 +4,7 @@ enum CodexThreadOwnershipState: String, Codable, Equatable, Sendable { case loaded case released case archived + case deleted } struct CodexThreadOwnershipRecord: Codable, Equatable, Sendable, Identifiable { diff --git a/Sources/CodexAdapter/CodexThreadOwnershipReconciliation.swift b/Sources/CodexAdapter/CodexThreadOwnershipReconciliation.swift index 385a2d6..1cf6458 100644 --- a/Sources/CodexAdapter/CodexThreadOwnershipReconciliation.swift +++ b/Sources/CodexAdapter/CodexThreadOwnershipReconciliation.swift @@ -1,7 +1,11 @@ -import CryptoKit -import Darwin import Foundation +#if canImport(CryptoKit) + import CryptoKit +#else + import Crypto +#endif + struct CodexThreadOwnershipReconciliationCandidate: Codable, Equatable, Sendable { let threadID: String let workspaceID: String? @@ -154,8 +158,8 @@ enum CodexThreadOwnershipReconciliation { ) throws -> Bool { guard let database else { return false } let lease = try database.codexRuntimeLeases(limit: 5_000).first { $0.id == runtimeID } - return processExists(lease?.process?.processID) - || processExists(lease?.process?.supervisorProcessID) + return codexRuntimeProcessMayExist(lease?.process?.processID) + || codexRuntimeProcessMayExist(lease?.process?.supervisorProcessID) } private static func makePlan( @@ -175,8 +179,8 @@ enum CodexThreadOwnershipReconciliation { private static func isSafelyGone(_ lease: CodexRuntimeLeaseRecord?) -> Bool { guard let lease else { return true } - if processExists(lease.process?.processID) - || processExists(lease.process?.supervisorProcessID) + if codexRuntimeProcessMayExist(lease.process?.processID) + || codexRuntimeProcessMayExist(lease.process?.supervisorProcessID) { return false } @@ -184,9 +188,4 @@ enum CodexThreadOwnershipReconciliation { || ["stopped", "cleaned", "failed", "running", "starting"].contains(lease.state) } - private static func processExists(_ processID: Int32?) -> Bool { - guard let processID, processID > 1 else { return false } - errno = 0 - return Darwin.kill(processID, 0) == 0 || errno != ESRCH - } } diff --git a/Sources/CodexAdapter/CodexWorkContinuation.swift b/Sources/CodexAdapter/CodexWorkContinuation.swift new file mode 100644 index 0000000..6daed75 --- /dev/null +++ b/Sources/CodexAdapter/CodexWorkContinuation.swift @@ -0,0 +1,129 @@ +import Foundation +import MCP + +/// Locators describe existing adapter-owned work, independently of authorization. +enum CodexWorkContinuation { + static let metadataKey = "io.github.computer-mcp/continuation" + + private static let threadKinds = [ + "thread", "turn", "goal", "queued-input", "server-request", "realtime", "mcp-login", + ] + private static let runtimeKinds = + threadKinds + [ + "call", "startup", "cleanup", "command", "process", "watch", "stream", "login", + "remote-control", + ] + + private struct Selector { + let kind: String + let handles: [String: String] + var nullable: Set = [] + var methods: [String] = [] + + var value: MCP.Value { + var fields: [String: MCP.Value] = [ + "kind": .string(kind), "handles": .object(handles.mapValues(MCP.Value.string)), + ] + if !nullable.isEmpty { + fields["nullable_handles"] = .array(nullable.sorted().map(MCP.Value.string)) + } + if !methods.isEmpty { + fields["when"] = .object([ + "pointer": .string("/method"), "values": .array(methods.sorted().map(MCP.Value.string)), + ]) + } + return .object(fields) + } + } + + static func declaration(for name: String) throws -> MCP.Value? { + let selectors: [Selector] + if name == "codex.app.methods.call" { + var grouped: [Selector] = [] + for method in CodexAppServerMethodCatalog.methods { + for selector in nativeSelectors(method) { + if let index = grouped.firstIndex(where: { + $0.kind == selector.kind && $0.handles == selector.handles + }) { + grouped[index].methods.append(method.method) + grouped[index].nullable.formUnion(selector.nullable) + } else { + var conditional = selector + conditional.methods = [method.method] + grouped.append(conditional) + } + } + } + selectors = grouped + } else if let method = CodexAppServerMethodCatalog.methods.first(where: { $0.toolName == name }) + { + selectors = nativeSelectors(method) + } else { + selectors = adapterSelectors(name) + } + guard !selectors.isEmpty else { return nil } + let value = MCP.Value.object([ + "format_version": .int(1), "selectors": .array(selectors.map(\.value)), + ]) + guard selectors.count <= 16, selectors.allSatisfy({ $0.methods.count <= 64 }), + try JSONEncoder().encode(value).count <= 16_384 + else { + throw SchemaError.invalid("Codex continuation declarations exceed the host metadata bounds.") + } + return value + } + + private static func nativeSelectors(_ method: CodexAppServerMethod) -> [Selector] { + switch method.method { + case "command/exec/write", "command/exec/resize", "command/exec/terminate": + return [nativeHandle("command", field: "processId")] + case "process/writeStdin", "process/resizePty", "process/kill": + return [nativeHandle("process", field: "processHandle")] + case "fs/unwatch": return [nativeHandle("watch", field: "watchId")] + case "mcpServer/event/stream/stop": return [nativeHandle("stream", field: "subscriptionId")] + case "account/login/cancel": + return [.init(kind: "codex.app.login", handles: ["login_id": "/params/loginId"])] + default: break + } + // A fork creates another task; its source thread is not the new task's owner. + guard method.method != "thread/fork", let required = method.threadParameters["threadId"] else { + return [] + } + return threadSelectors(pointer: "/params/threadId", nullable: !required) + } + + private static func nativeHandle(_ kind: String, field: String) -> Selector { + .init(kind: "codex.app." + kind, handles: ["native_id": "/params/" + field]) + } + + private static func threadSelectors(pointer: String, nullable: Bool = false) -> [Selector] { + threadKinds.map { + .init( + kind: "codex.app." + $0, handles: ["thread_id": pointer], + nullable: nullable ? ["thread_id"] : []) + } + } + + private static func adapterSelectors(_ name: String) -> [Selector] { + switch name { + case "codex.exec.events", "codex.exec.result", "codex.exec.cancel", "codex.exec.release": + return [.init(kind: "codex.exec.session", handles: ["id": "/session_id"])] + case "codex.app.thread.reclaim", "codex.app.thread.read", "codex.app.thread.turns.list", + "codex.app.thread.items.list", "codex.app.thread.recent", "codex.app.thread.release", + "codex.app.handoff.diagnose", "codex.app.goal.get", "codex.app.goal.set", + "codex.app.goal.clear", + "codex.app.turn.start", "codex.app.turn.steer", "codex.app.turn.interrupt", + "codex.app.review.start": + return threadSelectors(pointer: "/thread_id") + case "codex.app.requests.respond": + return [.init(kind: "codex.app.server-request", handles: ["request_id": "/request_id"])] + case "codex.app.approvals.read", "codex.app.approvals.respond": + return [.init(kind: "codex.app.server-request", handles: ["approval_id": "/approval_id"])] + case "codex.app.runtimes.inspect", "codex.app.runtimes.stop": + return runtimeKinds.map { + .init(kind: "codex.app." + $0, handles: ["runtime_id": "/runtime_id"]) + } + default: return [] + } + } +} diff --git a/Sources/CodexAdapter/CodexWorkOwnership.swift b/Sources/CodexAdapter/CodexWorkOwnership.swift new file mode 100644 index 0000000..39b7b3d --- /dev/null +++ b/Sources/CodexAdapter/CodexWorkOwnership.swift @@ -0,0 +1,121 @@ +import Foundation +import MCP + +/// Captured by the actual resource owner before starting asynchronous work. +enum CodexWorkInvocation { + static let metadataKey = "io.github.computer-mcp/work-invocation" + @TaskLocal static var current: UUID? + + static func parse(_ metadata: MCP.Metadata?) throws -> UUID? { + guard let value = metadata?[metadataKey] else { return nil } + guard let text = value.stringValue, text.utf8.count == 36, + let id = UUID(uuidString: text), id.uuidString.lowercased() == text.lowercased() + else { + throw MCPError.invalidParams("Work invocation must be a UUID.") + } + return id + } +} + +/// A notification can expose a native ID before its creating RPC returns. +/// Children share this once-bound origin, never a consumer task's context. +final class CodexWorkOrigin: @unchecked Sendable { + private let lock = NSLock() + private var resolved = false + private var invocation: UUID? + + init() {} + + init(invocation: UUID?) { + self.invocation = invocation + resolved = true + } + + func bind(to invocation: UUID?) { + lock.withLock { + guard !resolved else { return } + self.invocation = invocation + resolved = true + } + } + + var value: UUID? { lock.withLock { invocation } } +} + +struct CodexWorkBinding: Sendable { + let id = UUID().uuidString.lowercased() + let origin: CodexWorkOrigin + + init(origin: CodexWorkOrigin = .init()) { self.origin = origin } + + func resource( + _ kind: String, state: CodexWorkResource.State = .active, + handles: [String: JSONValue] = [:] + ) throws + -> CodexWorkResource + { + try CodexWorkResource( + kind: kind, id: id, acquiredBy: origin.value, state: state, handles: handles) + } +} + +struct CodexWorkResource: Equatable, Sendable { + enum State: String, Sendable { + case active + case uncertain + } + + let kind: String + let id: String + let acquiredBy: UUID + let state: State + let handles: [String: JSONValue] + + init( + kind: String, id: String, acquiredBy: UUID?, state: State = .active, + handles: [String: JSONValue] = [:] + ) throws { + guard let acquiredBy else { + throw MCPError.internalError("Work ownership is unavailable for an unbound \(kind) resource.") + } + self.kind = kind + self.id = id + self.acquiredBy = acquiredBy + self.state = state + guard handles.count <= 16, + handles.allSatisfy({ name, value in + guard name != "id", Self.validIdentifier(name) else { return false } + switch value { + case .string(let text): return Self.validIdentifier(text) + case .integer: return true + default: return false + } + }) + else { throw MCPError.internalError("Work continuation handles are invalid.") } + self.handles = handles + } + + var json: JSONValue { + var fields: [String: JSONValue] = [ + "kind": .string(kind), "id": .string(id), + "acquired_by": .string(acquiredBy.uuidString.lowercased()), + "state": .string(state.rawValue), + ] + if !handles.isEmpty { fields["handles"] = .object(handles) } + return .object(fields) + } + + func addingHandles(_ additions: [String: JSONValue]) throws -> Self { + guard additions.allSatisfy({ handles[$0.key] == nil || handles[$0.key] == $0.value }) else { + throw MCPError.internalError("Work continuation handles cannot be rebound.") + } + return try .init( + kind: kind, id: id, acquiredBy: acquiredBy, state: state, + handles: handles.merging(additions, uniquingKeysWith: { current, _ in current })) + } + + private static func validIdentifier(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 1024 + && !value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + } +} diff --git a/Sources/CodexAdapter/CodexWorkSnapshot.swift b/Sources/CodexAdapter/CodexWorkSnapshot.swift new file mode 100644 index 0000000..6285611 --- /dev/null +++ b/Sources/CodexAdapter/CodexWorkSnapshot.swift @@ -0,0 +1,93 @@ +import Foundation +import MCP + +/// One complete observation per read; failed or overlapping reads cannot imply idle work. +actor CodexWorkSnapshot { + static let uri = "computer-mcp://runtime/work/v1" + static let metadataKey = "io.github.computer-mcp/work" + + private let collect: @Sendable () async throws -> [CodexWorkResource] + private let instanceID = UUID().uuidString.lowercased() + private var revision: Int64 = 0 + private var previous: [CodexWorkResource]? + private var reading: Task? + private var closed = false + + init(collect: @escaping @Sendable () async throws -> [CodexWorkResource]) { + self.collect = collect + } + + static func declaring(_ tool: MCP.Tool) throws -> MCP.Tool { + var tool = tool + var fields = tool._meta?.fields ?? [:] + fields[metadataKey] = .object(["format_version": .int(1), "uri": .string(uri)]) + if let continuation = try CodexWorkContinuation.declaration(for: tool.name) { + fields[CodexWorkContinuation.metadataKey] = continuation + } + tool._meta = .init(additionalFields: fields) + return tool + } + + func read() async throws -> MCP.ReadResource.Result { + guard !closed else { throw MCPError.internalError("Work observation is closed.") } + // A read admitted after a completed call must not reuse an earlier observation. + guard reading == nil else { + throw MCPError.internalError("Work observation is already in progress; retry the read.") + } + let task = Task { + let resources = try await collect() + try Task.checkCancellation() + return try publish(resources) + } + reading = task + defer { reading = nil } + return try await task.value + } + + func shutdown() async { + closed = true + reading?.cancel() + _ = await reading?.result + } + + private func publish(_ resources: [CodexWorkResource]) throws -> MCP.ReadResource.Result { + guard !closed, resources.count <= 1024 else { + throw MCPError.internalError("Complete work observation is unavailable.") + } + let rows = resources.sorted { ($0.kind, $0.id) < ($1.kind, $1.id) } + for (index, row) in rows.enumerated() { + guard Self.isIdentifier(row.kind), Self.isIdentifier(row.id), + index == 0 || rows[index - 1].kind != row.kind || rows[index - 1].id != row.id + else { + throw MCPError.internalError("Work resource identity is invalid or ambiguous.") + } + } + let changed = previous != nil && previous != rows + guard !changed || revision < Int64.max else { + throw MCPError.internalError("Work observation revision is exhausted.") + } + let nextRevision = revision + (changed ? 1 : 0) + let value = JSONValue.object([ + "format_version": .integer(1), "instance_id": .string(instanceID), + "revision": .integer(nextRevision), "resources": .array(rows.map(\.json)), + ]) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(value) + guard data.count <= 524_288 else { + throw MCPError.internalError("Complete work observation exceeds its byte bound.") + } + previous = rows + revision = nextRevision + return .init(contents: [ + .text( + String(decoding: data, as: UTF8.self), uri: Self.uri, + mimeType: "application/json") + ]) + } + + private static func isIdentifier(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 1024 + && !value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + } +} diff --git a/Sources/CodexAdapter/CodexWorktreeFileSystem.swift b/Sources/CodexAdapter/CodexWorktreeFileSystem.swift new file mode 100644 index 0000000..8b043cc --- /dev/null +++ b/Sources/CodexAdapter/CodexWorktreeFileSystem.swift @@ -0,0 +1,224 @@ +import Foundation + +#if os(Windows) + import WinSDK +#else + import Darwin +#endif + +/// Filesystem ownership for managed Git operations; host authorization remains with the manager. +enum CodexWorktreeFileSystem { + #if os(Windows) + typealias Protection = WindowsPrivateDirectory + static let gitExecutable = "git" + #else + struct Protection: Sendable {} + static let gitExecutable = "/usr/bin/git" + #endif + + static func prepareRoot(_ root: URL) throws -> Protection { + #if os(Windows) + return try WindowsPrivateDirectory(root) + #else + let fileManager = FileManager.default + if fileManager.fileExists(atPath: root.path) { + let values = try root.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isDirectory == true, values.isSymbolicLink != true else { + throw CodexManagedWorktreeError.invalid( + "the managed worktree root must be a real directory" + ) + } + let attributes = try fileManager.attributesOfItem(atPath: root.path) + if let owner = attributes[.ownerAccountID] as? NSNumber, owner.uint32Value != getuid() { + throw CodexManagedWorktreeError.invalid( + "the managed worktree root is owned by another user" + ) + } + } else { + try fileManager.createDirectory( + at: root, + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] + ) + } + try fileManager.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: root.path + ) + + return Protection() + #endif + } + + static func prepareParent(_ parent: URL, containedIn root: URL) throws -> Protection { + #if os(Windows) + return try WindowsPrivateDirectory(creatingDirectory: parent, containedIn: root) + #else + try FileManager.default.createDirectory( + at: parent, withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: Int16(0o700))]) + return try protectDirectory(parent, containedIn: root) + #endif + } + + /// Inspection never creates a missing directory or changes an existing directory's permissions. + static func protectDirectory(_ directory: URL, containedIn root: URL) throws -> Protection { + #if os(Windows) + return try WindowsPrivateDirectory(existingDirectory: directory, containedIn: root) + #else + let values = try directory.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard values.isDirectory == true, values.isSymbolicLink != true else { + throw CodexManagedWorktreeError.invalid( + "managed worktree paths must be real directories, not symbolic links" + ) + } + let resolvedDirectory = directory.standardizedFileURL.resolvingSymlinksInPath() + let resolvedRoot = root.standardizedFileURL.resolvingSymlinksInPath() + guard + resolvedDirectory == resolvedRoot + || resolvedDirectory.path.hasPrefix(resolvedRoot.path + "/") + else { + throw CodexManagedWorktreeError.invalid( + "managed worktree path escaped the canonical managed root" + ) + } + + return Protection() + #endif + } + + static func validateDirectory(_ directory: URL, containedIn root: URL) throws { + let protection = try protectDirectory(directory, containedIn: root) + withExtendedLifetime(protection) {} + } + + /// A lexical precheck only. Existing directory containment also requires retained native ancestry. + static func isDescendant(_ directory: URL, of root: URL) throws -> Bool { + #if os(Windows) + let path = try nativePath(directory) + let parent = try nativePath(root) + let prefix = parent + (parent.hasSuffix("\\") ? "" : "\\") + let units = Array(path.utf16) + let prefixUnits = Array(prefix.utf16) + guard units.count > prefixUnits.count else { return false } + return CompareStringOrdinal( + units, Int32(prefixUnits.count), prefixUnits, Int32(prefixUnits.count), true) == CSTR_EQUAL + #else + return directory.standardizedFileURL.path.hasPrefix(root.standardizedFileURL.path + "/") + #endif + } + + static func samePath(_ lhs: URL, _ rhs: URL) throws -> Bool { + #if os(Windows) + let left = Array(try nativePath(lhs).utf16) + let right = Array(try nativePath(rhs).utf16) + return CompareStringOrdinal( + left, Int32(left.count), right, Int32(right.count), true) == CSTR_EQUAL + #else + return lhs.standardizedFileURL.path == rhs.standardizedFileURL.path + #endif + } + + static func sameDirectory(_ lhs: URL, _ rhs: URL) throws -> Bool { + #if os(Windows) + let left = try openDirectory(lhs) + defer { CloseHandle(left) } + let right = try openDirectory(rhs) + defer { CloseHandle(right) } + return try WindowsDirectoryIdentity(left) == WindowsDirectoryIdentity(right) + #else + return lhs.path == rhs.path + #endif + } + + static func canonicalDirectory(_ path: String, relativeTo base: URL) throws -> URL { + #if os(Windows) + guard let absolute = WindowsFilePath.absolute(path, cwd: try nativePath(base)) else { + throw WindowsPrivateDirectoryError.invalid("Git returned an invalid native directory path.") + } + let handle = try openDirectory(URL(fileURLWithPath: absolute, isDirectory: true)) + defer { CloseHandle(handle) } + var output = [WCHAR](repeating: 0, count: 32_768) + let count = GetFinalPathNameByHandleW(handle, &output, DWORD(output.count), 0) + guard count > 0, count < output.count else { + throw WindowsPrivateDirectoryError.native("Resolve Git directory", GetLastError()) + } + var result = String(decoding: output.prefix(Int(count)), as: UTF16.self) + if result.hasPrefix("\\\\?\\UNC\\") { + result = "\\\\" + result.dropFirst(8) + } else if result.hasPrefix("\\\\?\\") { + result = String(result.dropFirst(4)) + } + guard WindowsFilePath.isValid(result), WindowsFilePath.isAbsolute(result) else { + throw WindowsPrivateDirectoryError.invalid( + "Git directory is outside the native path namespace.") + } + return URL(fileURLWithPath: result, isDirectory: true) + #else + let url = + path.hasPrefix("/") + ? URL(fileURLWithPath: path, isDirectory: true) + : base.appendingPathComponent(path, isDirectory: true) + return url.standardizedFileURL.resolvingSymlinksInPath() + #endif + } + + static func isAbsent(_ url: URL) throws -> Bool { + #if os(Windows) + let handle = CreateFileW( + Array(try nativePath(url).utf16) + [0], DWORD(FILE_READ_ATTRIBUTES), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), nil, DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT), nil) + if let handle, handle != INVALID_HANDLE_VALUE { + CloseHandle(handle) + return false + } + let code = GetLastError() + guard code == DWORD(ERROR_FILE_NOT_FOUND) || code == DWORD(ERROR_PATH_NOT_FOUND) else { + throw WindowsPrivateDirectoryError.native("Verify managed path absence", code) + } + return true + #else + var info = stat() + return lstat(url.path, &info) != 0 && errno == ENOENT + #endif + } + + static func provisionPathIsAvailable(_ url: URL) throws -> Bool { + #if os(Windows) + return try isAbsent(url) + #else + return !FileManager.default.fileExists(atPath: url.path) + #endif + } + + #if os(Windows) + private static func nativePath(_ url: URL) throws -> String { + guard let native = WindowsFilePath.native(url), WindowsFilePath.isAbsolute(native), + let absolute = WindowsFilePath.absolute(native, cwd: native) + else { + throw WindowsPrivateDirectoryError.invalid("Managed paths must be native absolute paths.") + } + return absolute + } + + private static func openDirectory(_ url: URL) throws -> HANDLE { + guard + let handle = CreateFileW( + Array(try nativePath(url).utf16) + [0], DWORD(FILE_READ_ATTRIBUTES), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), nil, DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_BACKUP_SEMANTICS), nil), handle != INVALID_HANDLE_VALUE + else { throw WindowsPrivateDirectoryError.native("Open Git directory", GetLastError()) } + var information = BY_HANDLE_FILE_INFORMATION() + guard GetFileType(handle) == DWORD(FILE_TYPE_DISK), + GetFileInformationByHandle(handle, &information), + information.dwFileAttributes & DWORD(FILE_ATTRIBUTE_DIRECTORY) != 0 + else { + let code = GetLastError() + CloseHandle(handle) + throw WindowsPrivateDirectoryError.native("Inspect Git directory", code) + } + return handle + } + #endif +} diff --git a/Sources/CodexAdapter/CommandRunner+Darwin.swift b/Sources/CodexAdapter/CommandRunner+Darwin.swift new file mode 100644 index 0000000..1e11805 --- /dev/null +++ b/Sources/CodexAdapter/CommandRunner+Darwin.swift @@ -0,0 +1,169 @@ +#if !os(Windows) + import Foundation + + final class ProcessCommandRunner: CommandRunning, @unchecked Sendable { + init() {} + + func run( + executable: String, + arguments: [String], + workingDirectory: URL?, + environment: [String: String], + timeoutMilliseconds: Int, + maxOutputBytes: Int + ) throws -> CommandResult { + let result = try runData( + executable: executable, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + timeoutMilliseconds: timeoutMilliseconds, + maxOutputBytes: maxOutputBytes + ) + return CommandResult( + executable: result.executable, + arguments: result.arguments, + exitCode: result.exitCode, + timedOut: result.timedOut, + stdout: result.stdoutString, + stderr: result.stderrString, + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated + ) + } + + func runData( + executable: String, + arguments: [String], + workingDirectory: URL?, + environment: [String: String], + timeoutMilliseconds: Int, + maxOutputBytes: Int + ) throws -> CommandDataResult { + let process = Process() + configure(process: process, executable: executable, arguments: arguments) + process.currentDirectoryURL = workingDirectory + process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new + } + + let perStreamLimit = max(1, maxOutputBytes) + let stdout = OutputCollector(limit: perStreamLimit) + let stderr = OutputCollector(limit: perStreamLimit) + process.standardOutput = stdout.pipe + process.standardError = stderr.pipe + + let termination = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in termination.signal() } + + do { + try process.run() + } catch { + throw CommandRunnerError.launchFailed(error.localizedDescription) + } + + let waitResult = termination.wait(timeout: .now() + .milliseconds(timeoutMilliseconds)) + var timedOut = false + if waitResult == .timedOut { + timedOut = true + process.terminate() + _ = termination.wait(timeout: .now() + .seconds(2)) + if process.isRunning { + process.interrupt() + } + } + + let processHasExited = !process.isRunning + stdout.stop(processHasExited: processHasExited) + stderr.stop(processHasExited: processHasExited) + + return CommandDataResult( + executable: executable, + arguments: arguments, + exitCode: process.isRunning ? nil : process.terminationStatus, + timedOut: timedOut, + stdout: stdout.dataValue, + stderr: stderr.dataValue, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated + ) + } + } + + func configure(process: Process, executable: String, arguments: [String]) { + if executable.contains("/") { + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + } else { + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executable] + arguments + } + } + + final class OutputCollector: @unchecked Sendable { + let pipe = Pipe() + private let limit: Int + private let lock = NSLock() + private let endOfFile = DispatchSemaphore(value: 0) + private var data = Data() + private(set) var truncated = false + + init(limit: Int) { + self.limit = limit + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { + self?.endOfFile.signal() + return + } + self?.append(chunk) + } + } + + var stringValue: String { + lock.lock() + defer { lock.unlock() } + return String(decoding: data, as: UTF8.self) + } + + var dataValue: Data { + lock.lock() + defer { lock.unlock() } + return data + } + + func stop(processHasExited: Bool) { + if processHasExited { + // Process termination can race the final readability callback. Waiting for + // EOF ensures every earlier callback has appended its bytes before the + // result snapshot is created. + _ = endOfFile.wait(timeout: .now() + .seconds(2)) + } + pipe.fileHandleForReading.readabilityHandler = nil + if processHasExited { + append(pipe.fileHandleForReading.readDataToEndOfFile()) + } + } + + private func append(_ chunk: Data) { + lock.lock() + defer { lock.unlock() } + + guard !chunk.isEmpty else { + return + } + + let remaining = limit - data.count + if remaining <= 0 { + truncated = true + return + } + + if chunk.count > remaining { + data.append(chunk.prefix(remaining)) + truncated = true + } else { + data.append(chunk) + } + } + } +#endif diff --git a/Sources/CodexAdapter/CommandRunner+Windows.swift b/Sources/CodexAdapter/CommandRunner+Windows.swift new file mode 100644 index 0000000..5671bef --- /dev/null +++ b/Sources/CodexAdapter/CommandRunner+Windows.swift @@ -0,0 +1,129 @@ +#if os(Windows) + import Dispatch + import Foundation + import Synchronization + import WinSDK + + final class ProcessCommandRunner: CommandRunning, Sendable { + private let baseEnvironment: [String: String] + + init(environment: [String: String] = ProcessInfo.processInfo.environment) { + baseEnvironment = environment + } + + /// Synchronous compatibility entry point. Call from a blocking executor, not the main actor. + func runData( + executable: String, arguments: [String], workingDirectory: URL?, + environment: [String: String], timeoutMilliseconds: Int, maxOutputBytes: Int + ) throws -> CommandDataResult { + let result = Mutex?>(nil) + let completed = DispatchSemaphore(value: 0) + // The synchronous protocol has no task cancellation channel. Its deadline owns termination. + Task.detached { + let value: Result + do { + value = .success( + try await self.runDataAsync( + executable: executable, arguments: arguments, workingDirectory: workingDirectory, + environment: environment, timeoutMilliseconds: timeoutMilliseconds, + maxOutputBytes: maxOutputBytes)) + } catch { value = .failure(error) } + result.withLock { $0 = value } + completed.signal() + } + completed.wait() + return try result.withLock { try $0!.get() } + } + + func run( + executable: String, arguments: [String], workingDirectory: URL?, + environment: [String: String], timeoutMilliseconds: Int, maxOutputBytes: Int + ) throws -> CommandResult { + let value = try runData( + executable: executable, arguments: arguments, workingDirectory: workingDirectory, + environment: environment, timeoutMilliseconds: timeoutMilliseconds, + maxOutputBytes: maxOutputBytes) + return CommandResult( + executable: value.executable, arguments: value.arguments, exitCode: value.exitCode, + timedOut: value.timedOut, stdout: value.stdoutString, stderr: value.stderrString, + stdoutTruncated: value.stdoutTruncated, stderrTruncated: value.stderrTruncated) + } + + func runDataAsync( + executable: String, arguments: [String], workingDirectory: URL?, + environment: [String: String], timeoutMilliseconds: Int, maxOutputBytes: Int + ) async throws -> CommandDataResult { + try Task.checkCancellation() + guard (1...3_600_000).contains(timeoutMilliseconds), + (1...32_000_000).contains(maxOutputBytes), + arguments.allSatisfy({ !$0.contains("\0") }) + else { + throw CommandRunnerError.launchFailed("Invalid command arguments or execution limits.") + } + let cwd = workingDirectory ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + guard let nativeCwd = WindowsFilePath.native(cwd), WindowsFilePath.isValid(nativeCwd), + WindowsFilePath.isAbsolute(nativeCwd) + else { throw CommandRunnerError.launchFailed("Invalid command working directory.") } + let childEnvironment = try WindowsProcessEnvironment.merging( + baseEnvironment, overrides: environment) + let resolved = try WindowsExecutable.resolve( + executable, workspace: cwd, environment: childEnvironment) + guard let path = WindowsFilePath.native(resolved) else { + throw CommandRunnerError.launchFailed("Invalid command executable path.") + } + let job = try WindowsProcessJob() + // Native IO must finish before its buffers and handles are released. + // Cancellation stops the job, while this owner drains and joins without task cancellation. + let owner = Task.detached { + try await self.execute( + path: path, arguments: arguments, cwd: nativeCwd, environment: childEnvironment, + timeoutMilliseconds: timeoutMilliseconds, limit: maxOutputBytes, job: job) + } + return try await withTaskCancellationHandler { + let value = try await owner.value + try Task.checkCancellation() + return CommandDataResult( + executable: executable, arguments: arguments, exitCode: value.exitCode, + timedOut: job.timedOut, stdout: value.stdout.data, stderr: value.stderr.data, + stdoutTruncated: value.stdout.truncated, stderrTruncated: value.stderr.truncated) + } onCancel: { + job.stop(.cancelled) + } + } + + private struct Outcome: Sendable { + let exitCode: Int32 + let stdout: WindowsCommandProcess.Capture + let stderr: WindowsCommandProcess.Capture + } + + private func execute( + path: String, arguments: [String], cwd: String, environment: [String: String], + timeoutMilliseconds: Int, limit: Int, job: WindowsProcessJob + ) async throws -> Outcome { + try await withThrowingTaskGroup(of: Void.self) { timers in + timers.addTask { + do { try await Task.sleep(for: .milliseconds(timeoutMilliseconds)) } catch { return } + job.stop(.timedOut) + } + defer { timers.cancelAll() } + do { + let process = try WindowsCommandProcess( + path: path, arguments: arguments, cwd: cwd, environment: environment, job: job) + async let stdout = process.stdout.capture(limit: limit, job: job) + async let stderr = process.stderr.capture(limit: limit, job: job) + async let root = process.wait(job: job) + let values = await (stdout, stderr, root) + try await job.confirmCleanup() + return try Outcome( + exitCode: values.2.get(), stdout: values.0.get(), stderr: values.1.get()) + } catch { + job.stop(.failed) + try await job.confirmCleanup() + throw error + } + } + } + + } +#endif diff --git a/Sources/CodexAdapter/CommandRunner.swift b/Sources/CodexAdapter/CommandRunner.swift index 2f1b7a1..5398739 100644 --- a/Sources/CodexAdapter/CommandRunner.swift +++ b/Sources/CodexAdapter/CommandRunner.swift @@ -115,168 +115,3 @@ enum CommandRunnerError: Error, LocalizedError, Equatable { } } } - -final class ProcessCommandRunner: CommandRunning, @unchecked Sendable { - init() {} - - func run( - executable: String, - arguments: [String], - workingDirectory: URL?, - environment: [String: String], - timeoutMilliseconds: Int, - maxOutputBytes: Int - ) throws -> CommandResult { - let result = try runData( - executable: executable, - arguments: arguments, - workingDirectory: workingDirectory, - environment: environment, - timeoutMilliseconds: timeoutMilliseconds, - maxOutputBytes: maxOutputBytes - ) - return CommandResult( - executable: result.executable, - arguments: result.arguments, - exitCode: result.exitCode, - timedOut: result.timedOut, - stdout: result.stdoutString, - stderr: result.stderrString, - stdoutTruncated: result.stdoutTruncated, - stderrTruncated: result.stderrTruncated - ) - } - - func runData( - executable: String, - arguments: [String], - workingDirectory: URL?, - environment: [String: String], - timeoutMilliseconds: Int, - maxOutputBytes: Int - ) throws -> CommandDataResult { - let process = Process() - configure(process: process, executable: executable, arguments: arguments) - process.currentDirectoryURL = workingDirectory - process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new } - - let perStreamLimit = max(1, maxOutputBytes) - let stdout = OutputCollector(limit: perStreamLimit) - let stderr = OutputCollector(limit: perStreamLimit) - process.standardOutput = stdout.pipe - process.standardError = stderr.pipe - - let termination = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in termination.signal() } - - do { - try process.run() - } catch { - throw CommandRunnerError.launchFailed(error.localizedDescription) - } - - let waitResult = termination.wait(timeout: .now() + .milliseconds(timeoutMilliseconds)) - var timedOut = false - if waitResult == .timedOut { - timedOut = true - process.terminate() - _ = termination.wait(timeout: .now() + .seconds(2)) - if process.isRunning { - process.interrupt() - } - } - - let processHasExited = !process.isRunning - stdout.stop(processHasExited: processHasExited) - stderr.stop(processHasExited: processHasExited) - - return CommandDataResult( - executable: executable, - arguments: arguments, - exitCode: process.isRunning ? nil : process.terminationStatus, - timedOut: timedOut, - stdout: stdout.dataValue, - stderr: stderr.dataValue, - stdoutTruncated: stdout.truncated, - stderrTruncated: stderr.truncated - ) - } -} - -func configure(process: Process, executable: String, arguments: [String]) { - if executable.contains("/") { - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - } else { - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [executable] + arguments - } -} - -final class OutputCollector: @unchecked Sendable { - let pipe = Pipe() - private let limit: Int - private let lock = NSLock() - private let endOfFile = DispatchSemaphore(value: 0) - private var data = Data() - private(set) var truncated = false - - init(limit: Int) { - self.limit = limit - pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let chunk = handle.availableData - guard !chunk.isEmpty else { - self?.endOfFile.signal() - return - } - self?.append(chunk) - } - } - - var stringValue: String { - lock.lock() - defer { lock.unlock() } - return String(decoding: data, as: UTF8.self) - } - - var dataValue: Data { - lock.lock() - defer { lock.unlock() } - return data - } - - func stop(processHasExited: Bool) { - if processHasExited { - // Process termination can race the final readability callback. Waiting for - // EOF ensures every earlier callback has appended its bytes before the - // result snapshot is created. - _ = endOfFile.wait(timeout: .now() + .seconds(2)) - } - pipe.fileHandleForReading.readabilityHandler = nil - if processHasExited { - append(pipe.fileHandleForReading.readDataToEndOfFile()) - } - } - - private func append(_ chunk: Data) { - lock.lock() - defer { lock.unlock() } - - guard !chunk.isEmpty else { - return - } - - let remaining = limit - data.count - if remaining <= 0 { - truncated = true - return - } - - if chunk.count > remaining { - data.append(chunk.prefix(remaining)) - truncated = true - } else { - data.append(chunk) - } - } -} diff --git a/Sources/CodexAdapter/JSONValue.swift b/Sources/CodexAdapter/JSONValue.swift index 41d3886..986ed96 100644 --- a/Sources/CodexAdapter/JSONValue.swift +++ b/Sources/CodexAdapter/JSONValue.swift @@ -4,6 +4,7 @@ import Foundation package enum JSONValue: Codable, Equatable, Sendable { case string(String) case number(Double) + case integer(Int64) case bool(Bool) case object([String: JSONValue]) case array([JSONValue]) @@ -14,7 +15,14 @@ package enum JSONValue: Codable, Equatable, Sendable { if container.decodeNil() { self = .null + } else if let value = try? container.decode(Int64.self) { + self = .integer(value) } else if let value = try? container.decode(Double.self) { + guard value.isFinite, value.rounded() != value else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "JSON integer is outside the supported signed 64-bit range.") + } self = .number(value) } else if let value = try? container.decode(Bool.self) { self = .bool(value) @@ -42,6 +50,19 @@ package enum JSONValue: Codable, Equatable, Sendable { case .string(let value): try container.encode(value) case .number(let value): + if let integer = Int64(exactly: value) { + try container.encode(integer) + } else { + guard !value.isFinite || value.rounded() != value else { + throw EncodingError.invalidValue( + value, + .init( + codingPath: encoder.codingPath, + debugDescription: "JSON integer is outside the supported signed 64-bit range.")) + } + try container.encode(value) + } + case .integer(let value): try container.encode(value) case .bool(let value): try container.encode(value) @@ -62,16 +83,18 @@ package enum JSONValue: Codable, Equatable, Sendable { return nil } - /// Returns the underlying number if this value is a number. + /// Returns a floating-point approximation. Use intValue for integer identities. package var numberValue: Double? { - if case .number(let value) = self { - return value + switch self { + case .number(let value): return value + case .integer(let value): return Double(value) + default: return nil } - return nil } /// Returns the underlying number as an integer when it is integral. package var intValue: Int? { + if case .integer(let value) = self { return Int(exactly: value) } guard let numberValue else { return nil } @@ -87,6 +110,21 @@ package enum JSONValue: Codable, Equatable, Sendable { return Int(rounded) } + package static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.integer(let lhs), .integer(let rhs)): lhs == rhs + case (.number(let lhs), .number(let rhs)): lhs == rhs + case (.integer(let lhs), .number(let rhs)): Int64(exactly: rhs) == lhs + case (.number(let lhs), .integer(let rhs)): Int64(exactly: lhs) == rhs + case (.string(let lhs), .string(let rhs)): lhs == rhs + case (.bool(let lhs), .bool(let rhs)): lhs == rhs + case (.object(let lhs), .object(let rhs)): lhs == rhs + case (.array(let lhs), .array(let rhs)): lhs == rhs + case (.null, .null): true + default: false + } + } + /// Returns the underlying Boolean if this value is a Boolean. package var boolValue: Bool? { if case .bool(let value) = self { diff --git a/Sources/CodexAdapter/MCPInheritedPipeTransport.swift b/Sources/CodexAdapter/MCPInheritedPipeTransport.swift new file mode 100644 index 0000000..6748889 --- /dev/null +++ b/Sources/CodexAdapter/MCPInheritedPipeTransport.swift @@ -0,0 +1,191 @@ +#if os(Windows) + import Foundation + import Logging + import MCP + @preconcurrency import SystemPackage + import WinSDK + import ucrt + + /// Read and write endpoints belong to this child, independently of its standard streams. + struct MCPInheritedPipeEndpoint: Sendable { + static let readEnvironmentKey = "COMPUTER_MCP_HOST_READ_HANDLE" + static let writeEnvironmentKey = "COMPUTER_MCP_HOST_WRITE_HANDLE" + let input: MCPInheritedPipeHandle + let output: MCPInheritedPipeHandle + + static func inherited(environment: [String: String]) throws -> Self? { + func value(_ name: String) throws -> String? { + let matches = environment.filter { $0.key.caseInsensitiveCompare(name) == .orderedSame } + guard matches.count <= 1 else { + throw MCPError.invalidParams("Ambiguous inherited host environment.") + } + return matches.first?.value + } + let read = try value(readEnvironmentKey) + let write = try value(writeEnvironmentKey) + let legacy = try value("COMPUTER_MCP_HOST_FD") + guard read != nil || write != nil || legacy != nil else { return nil } + guard legacy == nil, try value("COMPUTER_MCP_HOST_CONTEXT") != nil, + let read, let write, read != write + else { throw MCPError.invalidParams("Incomplete inherited host pipe endpoints.") } + let input = try inheritedHandle(read) + let output = try inheritedHandle(write) + return Self( + input: MCPInheritedPipeHandle(takingOwnershipOf: input), + output: MCPInheritedPipeHandle(takingOwnershipOf: output)) + } + + private static func inheritedHandle(_ text: String) throws -> HANDLE { + guard let value = UInt(text), String(value) == text, value > 0, value < UInt.max, + let handle = HANDLE(bitPattern: value) + else { throw MCPError.invalidParams("Invalid inherited host pipe handle.") } + var flags: DWORD = 0 + var mode: DWORD = 0 + guard handle != GetStdHandle(STD_INPUT_HANDLE), handle != GetStdHandle(STD_OUTPUT_HANDLE), + handle != GetStdHandle(STD_ERROR_HANDLE), GetHandleInformation(handle, &flags), + flags & DWORD(HANDLE_FLAG_INHERIT) != 0, GetFileType(handle) == DWORD(FILE_TYPE_PIPE), + GetNamedPipeInfo(handle, &mode, nil, nil, nil), mode & DWORD(PIPE_TYPE_MESSAGE) == 0 + else { throw MCPError.invalidParams("Host transport requires inherited byte pipes.") } + return handle + } + } + + /// Owns inherited endpoints until the MCP transport has duplicated them for native I/O. + /// No endpoint name, listener or credential is accepted from tool arguments. + actor MCPInheritedPipeTransport: Transport { + nonisolated let logger = Logger( + label: "mcp.inherited-pipe", factory: { _ in SwiftLogNoOpLogHandler() }) + private let descriptors: InheritedPipeDescriptors + private let base: StdioTransport + private var connection: Task, Error>? + private var stream = AsyncThrowingStream { + $0.finish(throwing: MCPError.connectionClosed) + } + private var closing: Task? + + init( + takingOwnershipOf endpoint: MCPInheritedPipeEndpoint, + maximumMessageBytes: Int = 1_048_576, maximumQueuedMessages: Int = 32, + writeTimeout: Duration = .seconds(30) + ) throws { + let descriptors = try InheritedPipeDescriptors(endpoint) + self.descriptors = descriptors + base = StdioTransport( + input: descriptors.input, output: descriptors.output, + maximumMessageBytes: maximumMessageBytes, maximumQueuedMessages: maximumQueuedMessages, + maximumQueuedBytes: 16_777_216, writeTimeout: writeTimeout) + } + + func connect() async throws { + guard closing == nil else { throw MCPError.connectionClosed } + if let connection { + stream = try await connection.value + try await base.connect() + return + } + let task = Task { [base, descriptors] in + defer { descriptors.close() } + try await base.connect() + return await base.receive() + } + connection = task + stream = try await task.value + guard closing == nil else { throw MCPError.connectionClosed } + } + + func send(_ data: Data) async throws { try await base.send(data) } + func receive() -> AsyncThrowingStream { stream } + + func disconnect() async { + if let closing { + await closing.value + return + } + let task = Task { [base, descriptors, connection] in + await base.disconnect() + _ = await connection?.result + descriptors.close() + } + closing = task + await task.value + } + } + + /// The lock serializes native handle borrowing with its once-only close. + final class MCPInheritedPipeHandle: @unchecked Sendable { + private let lock = NSLock() + private var handle: HANDLE? + init(takingOwnershipOf handle: HANDLE) { self.handle = handle } + func withHandle(_ body: (HANDLE) throws -> T) throws -> T { + try lock.withLock { + guard let handle else { throw MCPError.connectionClosed } + return try body(handle) + } + } + func close() { + lock.withLock { + guard let handle else { return } + self.handle = nil + CloseHandle(handle) + } + } + deinit { close() } + } + + private final class InheritedPipeDescriptors: @unchecked Sendable { + let input: FileDescriptor + let output: FileDescriptor + private let endpoint: MCPInheritedPipeEndpoint + private let lock = NSLock() + private var closed = false + + init(_ endpoint: MCPInheritedPipeEndpoint) throws { + self.endpoint = endpoint + input = try Self.duplicate(endpoint.input, reading: true) + do { + output = try Self.duplicate(endpoint.output, reading: false) + } catch { + _ = ucrt._close(input.rawValue) + throw error + } + } + + private static func duplicate(_ file: MCPInheritedPipeHandle, reading: Bool) throws + -> FileDescriptor + { + try file.withHandle { handle in + var mode: DWORD = 0 + guard GetFileType(handle) == DWORD(FILE_TYPE_PIPE), + GetNamedPipeInfo(handle, &mode, nil, nil, nil), mode & DWORD(PIPE_TYPE_MESSAGE) == 0, + SetHandleInformation(handle, DWORD(HANDLE_FLAG_INHERIT), 0) + else { throw MCPError.invalidParams("Host transport requires owned byte pipes.") } + var duplicate: HANDLE? + guard + DuplicateHandle( + GetCurrentProcess(), handle, GetCurrentProcess(), &duplicate, 0, false, + DWORD(DUPLICATE_SAME_ACCESS)), let duplicate + else { throw MCPError.connectionClosed } + let descriptor = ucrt._open_osfhandle( + Int(bitPattern: duplicate), (reading ? _O_RDONLY : _O_WRONLY) | _O_BINARY | _O_NOINHERIT) + guard descriptor >= 0 else { + CloseHandle(duplicate) + throw MCPError.connectionClosed + } + return FileDescriptor(rawValue: descriptor) + } + } + + func close() { + lock.withLock { + guard !closed else { return } + closed = true + _ = ucrt._close(input.rawValue) + _ = ucrt._close(output.rawValue) + endpoint.input.close() + endpoint.output.close() + } + } + + deinit { close() } + } +#endif diff --git a/Sources/CodexAdapter/MCPInheritedSocketTransport.swift b/Sources/CodexAdapter/MCPInheritedSocketTransport.swift index 3a68f62..728d42a 100644 --- a/Sources/CodexAdapter/MCPInheritedSocketTransport.swift +++ b/Sources/CodexAdapter/MCPInheritedSocketTransport.swift @@ -1,216 +1,221 @@ -import Darwin -import Foundation -import Logging -import MCP +#if !os(Windows) + import Darwin + import Foundation + import Logging + import MCP -/// A private inherited socket carries standard newline-delimited MCP messages. -/// It owns its descriptor; it never opens a pathname, listener or privileged socket. -actor MCPInheritedSocketTransport: Transport { - nonisolated let logger = Logger( - label: "mcp.inherited-socket", factory: { _ in SwiftLogNoOpLogHandler() }) - private let handle: FileHandle - private let maximumMessageBytes: Int - private let maximumQueuedMessages: Int - private let writeTimeout: Duration - private let stream: AsyncThrowingStream - private let continuation: AsyncThrowingStream.Continuation - private var connected = false - private var closed = false - private var pending = Data() - private var reader: Task? - private var writer: Task? - private var queuedWrites = 0 + /// A private inherited socket carries standard newline-delimited MCP messages. + /// It owns its descriptor; it never opens a pathname, listener or privileged socket. + actor MCPInheritedSocketTransport: Transport { + nonisolated let logger = Logger( + label: "mcp.inherited-socket", factory: { _ in SwiftLogNoOpLogHandler() }) + private let handle: FileHandle + private let maximumMessageBytes: Int + private let maximumQueuedMessages: Int + private let writeTimeout: Duration + private let stream: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + private var connected = false + private var closed = false + private var pending = Data() + private var reader: Task? + private var writer: Task? + private var queuedWrites = 0 - init( - takingOwnershipOf handle: FileHandle, maximumMessageBytes: Int = 1_048_576, - maximumQueuedMessages: Int = 32, writeTimeout: Duration = .seconds(30) - ) throws { - guard (1...16_777_216).contains(maximumMessageBytes), - (1...256).contains(maximumQueuedMessages), writeTimeout > .zero - else { throw MCPError.invalidParams("Invalid inherited transport bounds.") } - let descriptor = handle.fileDescriptor - var info = stat() - var type: Int32 = 0 - var length = socklen_t(MemoryLayout.size) - var address = sockaddr_storage() - var addressLength = socklen_t(MemoryLayout.size) - let peerResult = withUnsafeMutablePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - getpeername(descriptor, $0, &addressLength) + init( + takingOwnershipOf handle: FileHandle, maximumMessageBytes: Int = 1_048_576, + maximumQueuedMessages: Int = 32, writeTimeout: Duration = .seconds(30) + ) throws { + guard (1...16_777_216).contains(maximumMessageBytes), + (1...256).contains(maximumQueuedMessages), writeTimeout > .zero + else { throw MCPError.invalidParams("Invalid inherited transport bounds.") } + let descriptor = handle.fileDescriptor + var info = stat() + var type: Int32 = 0 + var length = socklen_t(MemoryLayout.size) + var address = sockaddr_storage() + var addressLength = socklen_t(MemoryLayout.size) + let peerResult = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getpeername(descriptor, $0, &addressLength) + } + } + guard descriptor >= 3, fstat(descriptor, &info) == 0, + info.st_mode & S_IFMT == S_IFSOCK, + getsockopt(descriptor, SOL_SOCKET, SO_TYPE, &type, &length) == 0, + type == SOCK_STREAM, peerResult == 0, Int32(address.ss_family) == AF_UNIX + else { + throw MCPError.invalidParams("Host transport requires a connected Unix stream socket.") } + let flags = fcntl(descriptor, F_GETFL) + let fdFlags = fcntl(descriptor, F_GETFD) + var noSigpipe: Int32 = 1 + guard flags >= 0, fdFlags >= 0, + fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0, + fcntl(descriptor, F_SETFD, fdFlags | FD_CLOEXEC) == 0, + setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, length) == 0 + else { throw MCPError.transportError(POSIXError(.EIO)) } + self.handle = handle + self.maximumMessageBytes = maximumMessageBytes + self.maximumQueuedMessages = maximumQueuedMessages + self.writeTimeout = writeTimeout + (stream, continuation) = AsyncThrowingStream.makeStream( + bufferingPolicy: .bufferingOldest(maximumQueuedMessages)) } - guard descriptor >= 3, fstat(descriptor, &info) == 0, - info.st_mode & S_IFMT == S_IFSOCK, - getsockopt(descriptor, SOL_SOCKET, SO_TYPE, &type, &length) == 0, - type == SOCK_STREAM, peerResult == 0, Int32(address.ss_family) == AF_UNIX - else { throw MCPError.invalidParams("Host transport requires a connected Unix stream socket.") } - let flags = fcntl(descriptor, F_GETFL) - let fdFlags = fcntl(descriptor, F_GETFD) - var noSigpipe: Int32 = 1 - guard flags >= 0, fdFlags >= 0, - fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0, - fcntl(descriptor, F_SETFD, fdFlags | FD_CLOEXEC) == 0, - setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, length) == 0 - else { throw MCPError.transportError(POSIXError(.EIO)) } - self.handle = handle - self.maximumMessageBytes = maximumMessageBytes - self.maximumQueuedMessages = maximumQueuedMessages - self.writeTimeout = writeTimeout - (stream, continuation) = AsyncThrowingStream.makeStream( - bufferingPolicy: .bufferingOldest(maximumQueuedMessages)) - } - static func makePair() throws -> (FileHandle, FileHandle) { - var descriptors: [Int32] = [-1, -1] - guard socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0 else { - throw MCPError.transportError(POSIXError(.EMFILE)) - } - let pair = ( - FileHandle(fileDescriptor: descriptors[0], closeOnDealloc: true), - FileHandle(fileDescriptor: descriptors[1], closeOnDealloc: true) - ) - for descriptor in descriptors { - let flags = fcntl(descriptor, F_GETFD) - guard flags >= 0, fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC) == 0 else { - try? pair.0.close() - try? pair.1.close() - throw MCPError.transportError(POSIXError(.EIO)) + static func makePair() throws -> (FileHandle, FileHandle) { + var descriptors: [Int32] = [-1, -1] + guard socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0 else { + throw MCPError.transportError(POSIXError(.EMFILE)) } + let pair = ( + FileHandle(fileDescriptor: descriptors[0], closeOnDealloc: true), + FileHandle(fileDescriptor: descriptors[1], closeOnDealloc: true) + ) + for descriptor in descriptors { + let flags = fcntl(descriptor, F_GETFD) + guard flags >= 0, fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC) == 0 else { + try? pair.0.close() + try? pair.1.close() + throw MCPError.transportError(POSIXError(.EIO)) + } + } + return pair } - return pair - } - func connect() async throws { - guard !closed else { throw MCPError.connectionClosed } - guard !connected else { return } - connected = true - reader = Task { [weak self] in - while !Task.isCancelled { - guard let self, await self.readAvailable() else { break } - do { try await Task.sleep(for: .milliseconds(5)) } catch { break } + func connect() async throws { + guard !closed else { throw MCPError.connectionClosed } + guard !connected else { return } + connected = true + reader = Task { [weak self] in + while !Task.isCancelled { + guard let self, await self.readAvailable() else { break } + do { try await Task.sleep(for: .milliseconds(5)) } catch { break } + } } } - } - func receive() -> AsyncThrowingStream { stream } + func receive() -> AsyncThrowingStream { stream } - func send(_ message: Data) async throws { - try Task.checkCancellation() - guard connected, !closed else { throw MCPError.connectionClosed } - guard !message.isEmpty, message.count <= maximumMessageBytes, - !message.contains(0x0A), String(data: message, encoding: .utf8) != nil - else { throw MCPError.invalidRequest("Invalid inherited MCP frame.") } - guard queuedWrites < maximumQueuedMessages else { - finish(throwing: MCPError.transportError(POSIXError(.ENOBUFS))) - throw MCPError.connectionClosed - } - queuedWrites += 1 - defer { queuedWrites -= 1 } - let predecessor = writer - var line = message - line.append(0x0A) - let payload = line - let deadline = ContinuousClock.now + writeTimeout - let task = Task { [weak self] in - _ = await predecessor?.result + func send(_ message: Data) async throws { try Task.checkCancellation() - guard let self else { throw MCPError.connectionClosed } - try await self.write(payload, deadline: deadline) - } - writer = task - try await withTaskCancellationHandler { - try await task.value - } onCancel: { - task.cancel() + guard connected, !closed else { throw MCPError.connectionClosed } + guard !message.isEmpty, message.count <= maximumMessageBytes, + !message.contains(0x0A), String(data: message, encoding: .utf8) != nil + else { throw MCPError.invalidRequest("Invalid inherited MCP frame.") } + guard queuedWrites < maximumQueuedMessages else { + finish(throwing: MCPError.transportError(POSIXError(.ENOBUFS))) + throw MCPError.connectionClosed + } + queuedWrites += 1 + defer { queuedWrites -= 1 } + let predecessor = writer + var line = message + line.append(0x0A) + let payload = line + let deadline = ContinuousClock.now + writeTimeout + let task = Task { [weak self] in + _ = await predecessor?.result + try Task.checkCancellation() + guard let self else { throw MCPError.connectionClosed } + try await self.write(payload, deadline: deadline) + } + writer = task + try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } } - } - func disconnect() async { - finish() - let reading = reader - let writing = writer - reader = nil - writer = nil - reading?.cancel() - writing?.cancel() - await reading?.value - _ = await writing?.result - } + func disconnect() async { + finish() + let reading = reader + let writing = writer + reader = nil + writer = nil + reading?.cancel() + writing?.cancel() + await reading?.value + _ = await writing?.result + } - private func write(_ data: Data, deadline: ContinuousClock.Instant) async throws { - var offset = 0 - do { - while offset < data.count { - try Task.checkCancellation() - guard connected, !closed else { throw MCPError.connectionClosed } - guard ContinuousClock.now < deadline else { - throw MCPError.transportError(POSIXError(.ETIMEDOUT)) - } - let count = data.withUnsafeBytes { - Darwin.send( - handle.fileDescriptor, $0.baseAddress!.advanced(by: offset), data.count - offset, 0) - } - if count > 0 { - offset += count - } else if count < 0 && errno == EINTR { - continue - } else if count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) { - try await Task.sleep(for: .milliseconds(5)) - } else { - throw MCPError.transportError(POSIXError(.EPIPE)) + private func write(_ data: Data, deadline: ContinuousClock.Instant) async throws { + var offset = 0 + do { + while offset < data.count { + try Task.checkCancellation() + guard connected, !closed else { throw MCPError.connectionClosed } + guard ContinuousClock.now < deadline else { + throw MCPError.transportError(POSIXError(.ETIMEDOUT)) + } + let count = data.withUnsafeBytes { + Darwin.send( + handle.fileDescriptor, $0.baseAddress!.advanced(by: offset), data.count - offset, 0) + } + if count > 0 { + offset += count + } else if count < 0 && errno == EINTR { + continue + } else if count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) { + try await Task.sleep(for: .milliseconds(5)) + } else { + throw MCPError.transportError(POSIXError(.EPIPE)) + } } + } catch { + // A partly transmitted request cannot be retried on this byte stream. + finish(throwing: error) + throw error } - } catch { - // A partly transmitted request cannot be retried on this byte stream. - finish(throwing: error) - throw error } - } - private func readAvailable() -> Bool { - guard connected, !closed else { return false } - var buffer = [UInt8](repeating: 0, count: 16_384) - let count = recv(handle.fileDescriptor, &buffer, buffer.count, 0) - if count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { return true } - guard count > 0 else { - finish() - return false - } - pending.append(contentsOf: buffer.prefix(count)) - while let newline = pending.firstIndex(of: 0x0A) { - let message = Data(pending[.. Bool { + guard connected, !closed else { return false } + var buffer = [UInt8](repeating: 0, count: 16_384) + let count = recv(handle.fileDescriptor, &buffer, buffer.count, 0) + if count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { return true } + guard count > 0 else { + finish() return false } - switch continuation.yield(message) { - case .enqueued: break - case .dropped, .terminated: - finish(throwing: MCPError.transportError(POSIXError(.ENOBUFS))) - return false - @unknown default: - finish(throwing: MCPError.connectionClosed) + pending.append(contentsOf: buffer.prefix(count)) + while let newline = pending.firstIndex(of: 0x0A) { + let message = Data(pending[.. + + private let configuration: Configuration + private let state: ManagedLineProcessState + private let launchTask: Task + private let closeLock = NSLock() + private var closeTask: Task? + + init(configuration: Configuration) throws { + try configuration.validate() + self.configuration = configuration + let streamAndContinuation = AsyncThrowingStream.makeStream( + bufferingPolicy: .bufferingOldest(16)) + self.inboundLines = streamAndContinuation.stream + let state = ManagedLineProcessState( + continuation: streamAndContinuation.continuation, + maximumMessageBytes: configuration.maximumMessageBytes, + ownerProcessID: configuration.ownerProcessID + ) + self.state = state + self.launchTask = Task.detached(priority: .userInitiated) { + await Self.launch(configuration: configuration, state: state) + } + } + + func sendLine(_ line: String) async throws { + try await state.send(line: line) + } + + func close() async { + let task = closeLock.withLock { () -> Task in + if let closeTask { + return closeTask + } + let task = Task { [weak self] in + if let self { + await self.performClose() + } + } + closeTask = task + return task + } + await task.value + } + + private func performClose() async { + await state.beginShutdown() + + // The child has a separate job-control group. Do not kill its supervisor before + // it publishes that group's identity, or a concurrently spawned child could escape. + // Readiness also ensures EOF delivery sees the input writer attached during launch. + let startupDeadline = ContinuousClock.now + .seconds(5) + while !(await state.canSignalOwnedProcess()) { + if ContinuousClock.now >= startupDeadline { + await state.failTerminationTimeout() + return + } + try? await Task.sleep(for: .milliseconds(10)) + } + + let finishInput = Task { + try? await state.finishInput() + } + + // Start EOF delivery without waiting for a back-pressured pipe writer. The + // bounded process wait and signal escalation below remain authoritative. + if await waitForExit(milliseconds: configuration.terminationGraceMilliseconds) { + await launchTask.value + await finishInput.value + return + } + + if !(await state.signalChildGroup(SIGTERM)), let execution = await state.runningExecution() { + try? execution.send(signal: .terminate, toProcessGroup: true) + } + if await waitForExit(milliseconds: configuration.terminationGraceMilliseconds) { + await launchTask.value + await finishInput.value + return + } + + if let execution = await state.runningExecution() { + await state.recordTerminationEscalation() + if !(await state.signalChildGroup(SIGKILL)) { + try? execution.send(signal: .kill, toProcessGroup: true) + } + } + + if !(await waitForExit(milliseconds: configuration.killGraceMilliseconds)) { + await state.failTerminationTimeout() + return + } + await launchTask.value + await finishInput.value + } + + func snapshot() async -> ManagedLineProcessSnapshot { + await state.snapshot() + } + + private func waitForExit(milliseconds: Int) async -> Bool { + let deadline = ContinuousClock.now + .milliseconds(max(0, milliseconds)) + repeat { + if await state.cleanupIsConfirmed() { + return true + } + if ContinuousClock.now >= deadline { + return false + } + try? await Task.sleep(for: .milliseconds(10)) + } while true + } + + private static func launch( + configuration: Configuration, + state: ManagedLineProcessState + ) async { + var supervisorDirectory: URL? + do { + if await state.isShuttingDown() { + await state.finish(status: .exited(0)) + return + } + let environment = Dictionary( + uniqueKeysWithValues: configuration.environment.compactMap { key, value in + Subprocess.Environment.Key(rawValue: key).map { ($0, value) } + } + ) + let supervisor = try makeSupervisor(configuration: configuration) + supervisorDirectory = supervisor.directory + let executable: Executable = .path(FilePath("/bin/sh")) + var platformOptions = PlatformOptions() + platformOptions.processGroupID = 0 + let standardOutput = try FileDescriptor.pipe() + let outputReader = try ManagedLineProcessOutputReader( + readEnd: standardOutput.readEnd, + state: state + ) + outputReader.start() + do { + let outcome = try await Subprocess.run( + executable, + arguments: Arguments(supervisor.arguments), + environment: .custom(environment), + workingDirectory: FilePath(configuration.workingDirectory.path), + platformOptions: platformOptions, + output: FileDescriptorOutput.fileDescriptor( + standardOutput.writeEnd, + closeAfterSpawningProcess: true + ) + ) { execution, inputWriter, stderr in + await state.attach( + execution: execution, + inputWriter: inputWriter + ) + guard let processID = await waitForProcessID(at: supervisor.processIDFile) + else { + try? execution.send(signal: .kill, toProcessGroup: true) + throw ManagedLineProcessError.launchFailed( + "The process supervisor did not report the child PID." + ) + } + await state.attachChild(processID: processID) + do { + for try await _ in stderr {} + } catch { + await state.recordStreamError(error) + } + } + await outputReader.stop(drainRemainingOutput: true) + await state.finish(status: outcome.terminationStatus) + } catch { + await outputReader.stop(drainRemainingOutput: false) + throw error + } + } catch { + await state.failLaunch(error) + } + if let supervisorDirectory { + try? FileManager.default.removeItem(at: supervisorDirectory) + } + } + + private struct SupervisorLaunch { + let directory: URL + let processIDFile: URL + let arguments: [String] + } + + private static func makeSupervisor( + configuration: Configuration + ) throws -> SupervisorLaunch { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("computer-mcp-process-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] + ) + let script = directory.appendingPathComponent("supervisor.sh") + let processIDFile = directory.appendingPathComponent("child.pid") + try Data( + """ + #!/bin/sh + set -m + pid_file=$1 + owner_pid=$2 + grace_seconds=$3 + shift 3 + + child= + watchdog= + timer= + cleanup_timer() { + if [ -n "$timer" ]; then + kill -KILL "$timer" 2>/dev/null || true + wait "$timer" 2>/dev/null || true + timer= + fi + } + terminate_group() { + kill -TERM -- -"$child" 2>/dev/null || true + # One owned timer measures elapsed grace independently of polling cost. + /bin/sleep "$grace_seconds" & + timer=$! + while kill -0 -- -"$child" 2>/dev/null; do + if ! kill -0 "$timer" 2>/dev/null; then + kill -KILL -- -"$child" 2>/dev/null || true + break + fi + /bin/sleep 0.01 + done + cleanup_timer + } + cleanup() { + trap - EXIT HUP INT TERM + if [ -n "$child" ]; then + if kill -0 -- -"$child" 2>/dev/null; then + terminate_group + fi + wait "$child" 2>/dev/null || true + fi + if [ -n "$watchdog" ]; then + kill -CONT "$watchdog" 2>/dev/null || true + kill -TERM "$watchdog" 2>/dev/null || true + wait "$watchdog" 2>/dev/null || true + fi + } + trap cleanup EXIT HUP INT TERM + + "$@" <&0 >&1 2>&2 & + child=$! + exec 0<&- + if ! printf '%s\n' "$child" > "$pid_file"; then exit 1; fi + ( + trap '' HUP INT + trap 'cleanup_timer; exit 0' TERM + while kill -0 "$owner_pid" 2>/dev/null; do + if ! kill -0 -- -"$child" 2>/dev/null; then exit 0; fi + /bin/sleep 0.1 + done + terminate_group + ) /dev/null 2>&1 & + watchdog=$! + + wait "$child" + status=$? + cleanup + exit "$status" + """.utf8 + ).write(to: script, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: script.path + ) + let graceSeconds = Double(configuration.terminationGraceMilliseconds) / 1_000 + return SupervisorLaunch( + directory: directory, + processIDFile: processIDFile, + arguments: [ + script.path, + processIDFile.path, + String(configuration.ownerProcessID), + String(graceSeconds), + configuration.executable, + ] + configuration.arguments + ) + } + + private static func waitForProcessID(at file: URL) async -> Int32? { + for _ in 0..<500 { + if Task.isCancelled { return nil } + if let text = try? String(contentsOf: file, encoding: .utf8), + let processID = Int32(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + return processID + } + do { try await Task.sleep(for: .milliseconds(10)) } catch { return nil } + } + return nil + } + + } + + /// File descriptor access and callback/task ownership are serialized by lock. Nonblocking reads + /// let shutdown drain a finite pipe without waiting on a descendant that retained stdout. + private final class ManagedLineProcessOutputReader: @unchecked Sendable { + private let handle: FileHandle + private let state: ManagedLineProcessState + private let lock = NSLock() + private var stopped = false + private var consumptionTask: Task? + + init( + readEnd: FileDescriptor, + state: ManagedLineProcessState + ) throws { + self.handle = FileHandle(fileDescriptor: readEnd.rawValue, closeOnDealloc: true) + self.state = state + let flags = fcntl(readEnd.rawValue, F_GETFL) + guard flags >= 0, fcntl(readEnd.rawValue, F_SETFL, flags | O_NONBLOCK) >= 0 else { + throw ManagedLineProcessError.launchFailed( + "Cannot configure the managed process output pipe.") + } + } + + func start() { + arm() + } + + func stop(drainRemainingOutput: Bool) async { + let task = lock.withLock { () -> Task? in + if !stopped { + stopped = true + handle.readabilityHandler = nil + if drainRemainingOutput { + // A pipe holds fewer bytes than this budget. A concurrent writer cannot make drain infinite. + var remaining = 1_048_576 + while remaining > 0, let data = readChunkLocked(), !data.isEmpty { + remaining -= data.count + enqueueLocked(data) + } + if remaining <= 0 { + let previous = consumptionTask + consumptionTask = Task { [state] in + await previous?.value + await state.recordStreamError(ManagedLineProcessError.bufferOverflow) + } + } + } + try? handle.close() + } + return consumptionTask + } + await task?.value + } + + private func arm() { + lock.withLock { armLocked() } + } + + private func armLocked() { + guard !stopped else { return } + handle.readabilityHandler = { [weak self] handle in + self?.consumeAvailableData(from: handle) + } + } + + private func consumeAvailableData(from handle: FileHandle) { + lock.withLock { + guard !stopped else { return } + handle.readabilityHandler = nil + guard let data = readChunkLocked() else { + armLocked() + return + } + guard !data.isEmpty else { + stopped = true + try? handle.close() + return + } + enqueueLocked(data) + } + } + + private func readChunkLocked() -> Data? { + var bytes = [UInt8](repeating: 0, count: 65_536) + while true { + let count = Darwin.read(handle.fileDescriptor, &bytes, bytes.count) + if count >= 0 { return Data(bytes.prefix(count)) } + if errno == EINTR { continue } + if errno == EAGAIN || errno == EWOULDBLOCK { return nil } + let previous = consumptionTask + consumptionTask = Task { [state] in + await previous?.value + await state.recordStreamError( + ManagedLineProcessError.launchFailed("Cannot read managed process stdout.")) + } + return Data() + } + } + + private func enqueueLocked(_ data: Data) { + let previous = consumptionTask + consumptionTask = Task { [weak self, state] in + await previous?.value + do { + try await state.appendStandardOutput(data) + self?.arm() + } catch { + await state.recordStreamError(error) + self?.stopProducing() + } + } + } + + private func stopProducing() { + lock.withLock { + guard !stopped else { return } + stopped = true + handle.readabilityHandler = nil + try? handle.close() + } + } + } + + private actor ManagedLineProcessState { + private let continuation: AsyncThrowingStream.Continuation + private let maximumMessageBytes: Int + private let ownerProcessID: Int32 + private var state: ManagedLineProcessSnapshot.State = .starting + private var execution: Execution? + private var inputWriter: StandardInputWriter? + private var processID: Int32? + private var supervisorProcessID: Int32? + private var startedAt: Date? + private var stoppedAt: Date? + private var exitCode: Int32? + private var signal: Int32? + private var terminationEscalated = false + private var pendingWrites = 0 + private var lastError: String? + private var outputBuffer = Data() + private var shutdownRequested = false + private var launchFinished = false + private var readyWaiters: [CheckedContinuation] = [] + + init( + continuation: AsyncThrowingStream.Continuation, + maximumMessageBytes: Int, + ownerProcessID: Int32 + ) { + self.continuation = continuation + self.maximumMessageBytes = maximumMessageBytes + self.ownerProcessID = ownerProcessID + } + + func attach(execution: Execution, inputWriter: StandardInputWriter) { + self.execution = execution + self.inputWriter = inputWriter + supervisorProcessID = Int32(execution.processIdentifier.value) + startedAt = Date() + if shutdownRequested { + state = .stopping + failReadyWaiters(ManagedLineProcessError.closed) + } + } + + func attachChild(processID: Int32) { + self.processID = processID + guard !shutdownRequested else { return } + state = .running + let waiters = readyWaiters + readyWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + func send(line: String) async throws { + try await waitUntilReady() + guard state == .running, let inputWriter else { + throw ManagedLineProcessError.closed + } + guard line.utf8.count <= maximumMessageBytes else { + throw ManagedLineProcessError.oversizedMessage(maximumMessageBytes) + } + guard !line.contains("\n") else { throw ManagedLineProcessError.invalidConfiguration } + let frame = Data((line + "\n").utf8) + pendingWrites += 1 + defer { pendingWrites -= 1 } + _ = try await inputWriter.write(Array(frame)) + } + + func appendStandardOutput(_ data: Data) throws { + guard !data.isEmpty else { return } + outputBuffer.append(data) + guard outputBuffer.count <= maximumMessageBytes || outputBuffer.contains(0x0A) else { + let error = ManagedLineProcessError.oversizedMessage(maximumMessageBytes) + continuation.finish(throwing: error) + throw error + } + + while let newline = outputBuffer.firstIndex(of: 0x0A) { + var line = outputBuffer[.. Execution? { + guard !hasFinished() else { return nil } + return execution + } + + func isShuttingDown() -> Bool { shutdownRequested } + + func canSignalOwnedProcess() -> Bool { processID != nil || hasFinished() } + + func signalChildGroup(_ signal: Int32) -> Bool { + // The live supervisor pins the child identity. Its exit does not authorize signalling a reused group. + guard !hasFinished(), let processID, processID > 1 else { return false } + return Darwin.kill(-processID, signal) == 0 || errno == ESRCH + } + + func recordTerminationEscalation() { + terminationEscalated = true + } + + func recordStreamError(_ error: Error) { + guard state != .stopped else { return } + lastError = Self.safeMessage(error) + continuation.finish(throwing: error) + } + + func finish(status: TerminationStatus) { + launchFinished = true + switch status { + case .exited(let code): + exitCode = code + case .signaled(let code): + signal = code + } + inputWriter = nil + execution = nil + stoppedAt = Date() + if state != .failed { + state = .stopped + } + failReadyWaiters(ManagedLineProcessError.closed) + continuation.finish() + } + + func failLaunch(_ error: Error) { + guard !hasFinished() else { return } + launchFinished = true + state = .failed + lastError = Self.safeMessage(error) + stoppedAt = Date() + inputWriter = nil + execution = nil + let launchError = ManagedLineProcessError.launchFailed( + Self.safeMessage(error) + ) + failReadyWaiters(launchError) + continuation.finish(throwing: launchError) + } + + func failTerminationTimeout() { + guard !cleanupIsConfirmed() else { return } + state = .failed + let error = ManagedLineProcessError.terminationTimedOut(processID: processID) + lastError = Self.safeMessage(error) + stoppedAt = Date() + failReadyWaiters(error) + continuation.finish(throwing: error) + } + + func hasFinished() -> Bool { + launchFinished && execution == nil + } + + func cleanupIsConfirmed() -> Bool { + guard hasFinished() else { return false } + guard let processID else { return supervisorProcessID == nil } + return Darwin.kill(-processID, 0) == -1 && errno == ESRCH + } + + func snapshot() -> ManagedLineProcessSnapshot { + ManagedLineProcessSnapshot( + state: hasFinished() && !cleanupIsConfirmed() ? .failed : state, + processID: processID, + supervisorProcessID: supervisorProcessID, + parentProcessID: ownerProcessID, + processGroupID: processID, + startedAt: startedAt, + stoppedAt: stoppedAt, + exitCode: exitCode, + signal: signal, + terminationEscalated: terminationEscalated, + pendingWrites: pendingWrites, + hasExited: hasFinished(), + lastError: hasFinished() && !cleanupIsConfirmed() + ? "Supervisor exited; child process-group cleanup is unconfirmed." : lastError, + cleanupConfirmed: cleanupIsConfirmed() + ) + } + + private func waitUntilReady() async throws { + switch state { + case .running: + return + case .starting: + try await withCheckedThrowingContinuation { continuation in + readyWaiters.append(continuation) + } + case .stopping, .stopped: + throw ManagedLineProcessError.closed + case .failed: + throw ManagedLineProcessError.launchFailed( + lastError ?? "Unknown launch failure." + ) + } + } + + private func failReadyWaiters(_ error: Error) { + let waiters = readyWaiters + readyWaiters.removeAll() + for waiter in waiters { + waiter.resume(throwing: error) + } + } + + private static func safeMessage(_ error: Error) -> String { + // Generic I/O errors can contain argv, environment values or protocol payloads. + guard let error = error as? ManagedLineProcessError else { + return "Managed process I/O failed." + } + return error.localizedDescription + } + } + +#endif diff --git a/Sources/CodexAdapter/ManagedLineProcess+Windows.swift b/Sources/CodexAdapter/ManagedLineProcess+Windows.swift new file mode 100644 index 0000000..80f5abb --- /dev/null +++ b/Sources/CodexAdapter/ManagedLineProcess+Windows.swift @@ -0,0 +1,240 @@ +#if os(Windows) + import CodexAppServerRuntime + import CodexAppServerStdio + import Foundation + import WinSDK + + /// The SDK owns launch, Job Object membership, pipes and joined native cleanup. + /// The adapter owns shutdown deadlines and the lifetime reported in runtime receipts. + final class ManagedLineProcess: Sendable { + let inboundLines: AsyncThrowingStream + private let transport: CodexAppServerStdioTransport + private let state: ManagedWindowsProcessState + private let lifecycle: Task + + init(configuration: Configuration) throws { + try configuration.validate() + // Retain the configured owner's identity before admitting a child. The current + // process is also protected by the SDK's non-inherited kill-on-close Job handle. + let parent = try ManagedWindowsProcessParent(processID: configuration.ownerProcessID) + let transport: CodexAppServerStdioTransport + do { + transport = try CodexAppServerStdioTransport( + configuration: .init( + executableURL: URL(fileURLWithPath: configuration.executable), + arguments: configuration.arguments, environment: configuration.environment, + workingDirectoryURL: configuration.workingDirectory, + maximumMessageBytes: configuration.maximumMessageBytes)) + } catch { + throw ManagedLineProcessError.launchFailed("Native process admission failed.") + } + self.transport = transport + self.inboundLines = transport.inboundLines + let state = ManagedWindowsProcessState(configuration: configuration, transport: transport) + self.state = state + let parentWait = parent.wait() + let parentCleanup = Task.detached { + switch await parentWait.value { + case .stopped: return + case .exited: await state.ownerEnded(failed: false) + case .failed: await state.ownerEnded(failed: true) + } + await transport.close() + } + self.lifecycle = Task.detached { + let result: Result + do { result = .success(try await transport.waitForExit()) } catch { + result = .failure(error) + } + parent.stop() + await parentCleanup.value + await state.finished(result) + } + } + + deinit { + let transport = transport + let lifecycle = lifecycle + Task { + await transport.close() + await lifecycle.value + } + } + + func sendLine(_ line: String) async throws { try await state.send(line) } + func close() async { + await state.close() + if await state.snapshot().cleanupConfirmed == true { await lifecycle.value } + } + func snapshot() async -> ManagedLineProcessSnapshot { await state.snapshot() } + } + + private actor ManagedWindowsProcessState { + private let configuration: ManagedLineProcess.Configuration + private let transport: CodexAppServerStdioTransport + private let startedAt = Date() + private var state: ManagedLineProcessSnapshot.State = .running + private var stoppedAt: Date? + private var exitCode: Int32? + private var signal: Int32? + private var terminationEscalated = false + private var pendingWrites = 0 + private var hasExited = false + private var cleanupConfirmed = false + private var lastError: String? + private var closeTask: Task? + private var inputFinish: Task? + private var forcedClose: Task? + + init(configuration: ManagedLineProcess.Configuration, transport: CodexAppServerStdioTransport) { + self.configuration = configuration + self.transport = transport + } + + func send(_ line: String) async throws { + guard state == .running else { throw ManagedLineProcessError.closed } + pendingWrites += 1 + defer { pendingWrites -= 1 } + do { try await transport.sendLine(line) } catch CodexAppServerStdioError.closed { + throw ManagedLineProcessError.closed + } catch let error as CodexAppServerConnectionFoundation.FoundationError { + switch error { + case .messageTooLarge(let limit): throw ManagedLineProcessError.oversizedMessage(limit) + case .embeddedNewline: throw ManagedLineProcessError.invalidConfiguration + case .bufferLimitExceeded: throw ManagedLineProcessError.bufferOverflow + default: throw ManagedLineProcessError.launchFailed("Managed process I/O failed.") + } + } catch { + throw ManagedLineProcessError.launchFailed("Managed process I/O failed.") + } + } + + func close() async { + if let closeTask { + await closeTask.value + return + } + if cleanupConfirmed { return } + if state == .running { state = .stopping } + // The actor retains the shutdown owner through every caller's cancellation. + let task = Task { await self.performClose() } + closeTask = task + await task.value + } + + private func performClose() async { + let finishing = Task { _ = try? await transport.finishInput() } + inputFinish = finishing + if await waitForCleanup(milliseconds: configuration.terminationGraceMilliseconds) { + await finishing.value + return + } + terminationEscalated = true + let forced = Task { await transport.close() } + forcedClose = forced + if await waitForCleanup(milliseconds: configuration.killGraceMilliseconds) { + await forced.value + await finishing.value + } else { + // These tasks keep the exact SDK owner alive after the reporting deadline. + // Only finished() can subsequently confirm cleanup and release the lifetime. + state = .failed + lastError = "Native process cleanup did not finish before the configured deadline." + } + } + + private func waitForCleanup(milliseconds: Int) async -> Bool { + let deadline = ContinuousClock.now + .milliseconds(milliseconds) + while !cleanupConfirmed { + if ContinuousClock.now >= deadline { return false } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func ownerEnded(failed: Bool) { + if state == .running { state = .stopping } + terminationEscalated = true + if failed { + state = .failed + lastError = "Native parent ownership observation failed." + } + } + + func finished(_ result: Result) async { + await inputFinish?.value + await forcedClose?.value + stoppedAt = Date() + switch result { + case .success(let termination): + hasExited = true + cleanupConfirmed = true + switch termination { + case .exited(let code): exitCode = code + case .signalled(let code): signal = code + } + if state != .failed { state = .stopped } + case .failure: + state = .failed + lastError = "Native process cleanup could not be confirmed." + } + } + + func snapshot() -> ManagedLineProcessSnapshot { + .init( + state: state, processID: transport.processIdentifier, supervisorProcessID: nil, + parentProcessID: configuration.ownerProcessID, processGroupID: nil, + startedAt: startedAt, stoppedAt: stoppedAt, exitCode: exitCode, signal: signal, + terminationEscalated: terminationEscalated, pendingWrites: pendingWrites, + hasExited: hasExited, lastError: lastError, cleanupConfirmed: cleanupConfirmed) + } + } + + /// Both non-inheritable handles remain immutable until the native waiter completes. + /// Closing the child never signals the borrowed owner process. + private final class ManagedWindowsProcessParent: @unchecked Sendable { + enum Result: Sendable { case exited, stopped, failed } + private let process: HANDLE + private let stopped: HANDLE + + init(processID: Int32) throws { + guard let process = OpenProcess(DWORD(SYNCHRONIZE), false, DWORD(bitPattern: processID)) + else { + throw ManagedLineProcessError.launchFailed("Cannot observe the configured process owner.") + } + guard WaitForSingleObject(process, 0) == DWORD(WAIT_TIMEOUT) else { + CloseHandle(process) + throw ManagedLineProcessError.launchFailed("The configured process owner is not running.") + } + guard let stopped = CreateEventW(nil, true, false, nil) else { + CloseHandle(process) + throw ManagedLineProcessError.launchFailed("Cannot create the process owner observation.") + } + self.process = process + self.stopped = stopped + } + + deinit { + CloseHandle(stopped) + CloseHandle(process) + } + + func stop() { _ = SetEvent(stopped) } + + func wait() -> Task { + Task.detached { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .utility).async { [self] in + var handles: [HANDLE?] = [stopped, process] + let result = WaitForMultipleObjects(2, &handles, false, DWORD(INFINITE)) + switch result { + case DWORD(WAIT_OBJECT_0): continuation.resume(returning: .stopped) + case DWORD(WAIT_OBJECT_0 + 1): continuation.resume(returning: .exited) + default: continuation.resume(returning: .failed) + } + } + } + } + } + } +#endif diff --git a/Sources/CodexAdapter/ManagedLineProcess.swift b/Sources/CodexAdapter/ManagedLineProcess.swift index 039450c..1a3eec2 100644 --- a/Sources/CodexAdapter/ManagedLineProcess.swift +++ b/Sources/CodexAdapter/ManagedLineProcess.swift @@ -1,7 +1,4 @@ -import Darwin import Foundation -import Subprocess -import System struct ManagedLineProcessSnapshot: Codable, Equatable, Sendable { enum State: String, Codable, Equatable, Sendable { @@ -25,6 +22,7 @@ struct ManagedLineProcessSnapshot: Codable, Equatable, Sendable { let pendingWrites: Int let hasExited: Bool let lastError: String? + var cleanupConfirmed: Bool? = nil private enum CodingKeys: String, CodingKey { case state @@ -40,6 +38,7 @@ struct ManagedLineProcessSnapshot: Codable, Equatable, Sendable { case pendingWrites = "pending_writes" case hasExited = "has_exited" case lastError = "last_error" + case cleanupConfirmed = "cleanup_confirmed" } } @@ -71,9 +70,7 @@ enum ManagedLineProcessError: Error, LocalizedError, Sendable { } } -/// Owns one supervisor generation. The lock protects closeTask; all process state is actor-owned. -/// Foundation's callback reader is the only other lock boundary; no host process is adopted. -final class ManagedLineProcess: @unchecked Sendable { +extension ManagedLineProcess { struct Configuration: Sendable { var executable: String var arguments: [String] @@ -92,7 +89,7 @@ final class ManagedLineProcess: @unchecked Sendable { terminationGraceMilliseconds: Int = 1_000, killGraceMilliseconds: Int = 2_000, maximumMessageBytes: Int = 1_024 * 1_024, - ownerProcessID: Int32 = getpid() + ownerProcessID: Int32 = ProcessInfo.processInfo.processIdentifier ) { self.executable = executable self.arguments = arguments @@ -105,684 +102,41 @@ final class ManagedLineProcess: @unchecked Sendable { } func validate() throws { - guard executable.hasPrefix("/"), !executable.contains("\0"), - workingDirectory.isFileURL, workingDirectory.path.hasPrefix("/"), - !workingDirectory.path.contains("\0"), - arguments.allSatisfy({ !$0.contains("\0") }), - environment.allSatisfy({ + #if os(Windows) + let validPaths = + Self.isAbsoluteWindowsPath(executable) + && workingDirectory.isFileURL && Self.isAbsoluteWindowsPath(workingDirectory.path) + let validOwner = UInt32(bitPattern: ownerProcessID) > 1 + let validEnvironment = environment.allSatisfy { + !$0.key.isEmpty && !$0.key.contains("\0") && !$0.value.contains("\0") + } + #else + let validPaths = + executable.hasPrefix("/") + && workingDirectory.isFileURL && workingDirectory.path.hasPrefix("/") + let validOwner = ownerProcessID > 1 + let validEnvironment = environment.allSatisfy { !$0.key.isEmpty && !$0.key.contains("=") && !$0.key.contains("\0") && !$0.value.contains("\0") - }), + } + #endif + guard validPaths, validOwner, validEnvironment, + !executable.contains("\0"), !workingDirectory.path.contains("\0"), + arguments.allSatisfy({ !$0.contains("\0") }), (0...30_000).contains(terminationGraceMilliseconds), (100...30_000).contains(killGraceMilliseconds), - (1...16_777_216).contains(maximumMessageBytes), ownerProcessID > 1 + (1...16_777_216).contains(maximumMessageBytes) else { throw ManagedLineProcessError.invalidConfiguration } } - } - - let inboundLines: AsyncThrowingStream - - private let configuration: Configuration - private let state: ManagedLineProcessState - private let launchTask: Task - private let closeLock = NSLock() - private var closeTask: Task? - - init(configuration: Configuration) throws { - try configuration.validate() - self.configuration = configuration - let streamAndContinuation = AsyncThrowingStream.makeStream( - bufferingPolicy: .bufferingOldest(16)) - self.inboundLines = streamAndContinuation.stream - let state = ManagedLineProcessState( - continuation: streamAndContinuation.continuation, - maximumMessageBytes: configuration.maximumMessageBytes, - ownerProcessID: configuration.ownerProcessID - ) - self.state = state - self.launchTask = Task.detached(priority: .userInitiated) { - await Self.launch(configuration: configuration, state: state) - } - } - - func sendLine(_ line: String) async throws { - try await state.send(line: line) - } - - func close() async { - let task = closeLock.withLock { () -> Task in - if let closeTask { - return closeTask - } - let task = Task { [weak self] in - if let self { - await self.performClose() - } - } - closeTask = task - return task - } - await task.value - } - - private func performClose() async { - await state.beginShutdown() - - // The child has a separate job-control group. Do not kill its supervisor before - // it publishes that group's identity, or a concurrently spawned child could escape. - // Readiness also ensures EOF delivery sees the input writer attached during launch. - let startupDeadline = ContinuousClock.now + .seconds(5) - while !(await state.canSignalOwnedProcess()) { - if ContinuousClock.now >= startupDeadline { - await state.failTerminationTimeout() - return - } - try? await Task.sleep(for: .milliseconds(10)) - } - - let finishInput = Task { - try? await state.finishInput() - } - - // Start EOF delivery without waiting for a back-pressured pipe writer. The - // bounded process wait and signal escalation below remain authoritative. - if await waitForExit(milliseconds: configuration.terminationGraceMilliseconds) { - await launchTask.value - await finishInput.value - return - } - - if !(await state.signalChildGroup(SIGTERM)), let execution = await state.runningExecution() { - try? execution.send(signal: .terminate, toProcessGroup: true) - } - if await waitForExit(milliseconds: configuration.terminationGraceMilliseconds) { - await launchTask.value - await finishInput.value - return - } - - if let execution = await state.runningExecution() { - await state.recordTerminationEscalation() - if !(await state.signalChildGroup(SIGKILL)) { - try? execution.send(signal: .kill, toProcessGroup: true) - } - } - - if !(await waitForExit(milliseconds: configuration.killGraceMilliseconds)) { - await state.failTerminationTimeout() - return - } - await launchTask.value - await finishInput.value - } - - func snapshot() async -> ManagedLineProcessSnapshot { - await state.snapshot() - } - - private func waitForExit(milliseconds: Int) async -> Bool { - let deadline = ContinuousClock.now + .milliseconds(max(0, milliseconds)) - repeat { - if await state.hasFinished() { - return true - } - if ContinuousClock.now >= deadline { - return false - } - try? await Task.sleep(for: .milliseconds(10)) - } while true - } - - private static func launch( - configuration: Configuration, - state: ManagedLineProcessState - ) async { - var supervisorDirectory: URL? - do { - if await state.isShuttingDown() { - await state.finish(status: .exited(0)) - return - } - let environment = Dictionary( - uniqueKeysWithValues: configuration.environment.compactMap { key, value in - Subprocess.Environment.Key(rawValue: key).map { ($0, value) } - } - ) - let supervisor = try makeSupervisor(configuration: configuration) - supervisorDirectory = supervisor.directory - let executable: Executable = .path(FilePath("/bin/sh")) - var platformOptions = PlatformOptions() - platformOptions.processGroupID = 0 - let standardOutput = try FileDescriptor.pipe() - let outputReader = try ManagedLineProcessOutputReader( - readEnd: standardOutput.readEnd, - state: state - ) - outputReader.start() - do { - let outcome = try await Subprocess.run( - executable, - arguments: Arguments(supervisor.arguments), - environment: .custom(environment), - workingDirectory: FilePath(configuration.workingDirectory.path), - platformOptions: platformOptions, - output: FileDescriptorOutput.fileDescriptor( - standardOutput.writeEnd, - closeAfterSpawningProcess: true - ) - ) { execution, inputWriter, stderr in - await state.attach( - execution: execution, - inputWriter: inputWriter - ) - guard let processID = await waitForProcessID(at: supervisor.processIDFile) - else { - try? execution.send(signal: .kill, toProcessGroup: true) - throw ManagedLineProcessError.launchFailed( - "The process supervisor did not report the child PID." - ) - } - await state.attachChild(processID: processID) - do { - for try await _ in stderr {} - } catch { - await state.recordStreamError(error) - } - } - await outputReader.stop(drainRemainingOutput: true) - await state.finish(status: outcome.terminationStatus) - } catch { - await outputReader.stop(drainRemainingOutput: false) - throw error - } - } catch { - await state.failLaunch(error) - } - if let supervisorDirectory { - try? FileManager.default.removeItem(at: supervisorDirectory) - } - } - - private struct SupervisorLaunch { - let directory: URL - let processIDFile: URL - let arguments: [String] - } - - private static func makeSupervisor( - configuration: Configuration - ) throws -> SupervisorLaunch { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("computer-mcp-process-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true, - attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] - ) - let script = directory.appendingPathComponent("supervisor.sh") - let processIDFile = directory.appendingPathComponent("child.pid") - try Data( - """ - #!/bin/sh - set -m - pid_file=$1 - owner_pid=$2 - grace_seconds=$3 - shift 3 - - child= - watchdog= - timer= - cleanup_timer() { - if [ -n "$timer" ]; then - kill -KILL "$timer" 2>/dev/null || true - wait "$timer" 2>/dev/null || true - timer= - fi - } - terminate_group() { - kill -TERM -- -"$child" 2>/dev/null || true - # One owned timer measures elapsed grace independently of polling cost. - /bin/sleep "$grace_seconds" & - timer=$! - while kill -0 -- -"$child" 2>/dev/null; do - if ! kill -0 "$timer" 2>/dev/null; then - kill -KILL -- -"$child" 2>/dev/null || true - break - fi - /bin/sleep 0.01 - done - cleanup_timer - } - cleanup() { - trap - EXIT HUP INT TERM - if [ -n "$child" ]; then - if kill -0 -- -"$child" 2>/dev/null; then - terminate_group - fi - wait "$child" 2>/dev/null || true - fi - if [ -n "$watchdog" ]; then - kill -CONT "$watchdog" 2>/dev/null || true - kill -TERM "$watchdog" 2>/dev/null || true - wait "$watchdog" 2>/dev/null || true - fi - } - trap cleanup EXIT HUP INT TERM - - "$@" <&0 >&1 2>&2 & - child=$! - exec 0<&- - if ! printf '%s\n' "$child" > "$pid_file"; then exit 1; fi - ( - trap '' HUP INT - trap 'cleanup_timer; exit 0' TERM - while kill -0 "$owner_pid" 2>/dev/null; do - /bin/sleep 0.1 - done - terminate_group - ) & - watchdog=$! - - wait "$child" - status=$? - cleanup - exit "$status" - """.utf8 - ).write(to: script, options: .atomic) - try FileManager.default.setAttributes( - [.posixPermissions: NSNumber(value: Int16(0o700))], - ofItemAtPath: script.path - ) - let graceSeconds = Double(configuration.terminationGraceMilliseconds) / 1_000 - return SupervisorLaunch( - directory: directory, - processIDFile: processIDFile, - arguments: [ - script.path, - processIDFile.path, - String(configuration.ownerProcessID), - String(graceSeconds), - configuration.executable, - ] + configuration.arguments - ) - } - - private static func waitForProcessID(at file: URL) async -> Int32? { - for _ in 0..<500 { - if Task.isCancelled { return nil } - if let text = try? String(contentsOf: file, encoding: .utf8), - let processID = Int32(text.trimmingCharacters(in: .whitespacesAndNewlines)) - { - return processID - } - do { try await Task.sleep(for: .milliseconds(10)) } catch { return nil } - } - return nil - } - -} - -/// File descriptor access and callback/task ownership are serialized by lock. Nonblocking reads -/// let shutdown drain a finite pipe without waiting on a descendant that retained stdout. -private final class ManagedLineProcessOutputReader: @unchecked Sendable { - private let handle: FileHandle - private let state: ManagedLineProcessState - private let lock = NSLock() - private var stopped = false - private var consumptionTask: Task? - - init( - readEnd: FileDescriptor, - state: ManagedLineProcessState - ) throws { - self.handle = FileHandle(fileDescriptor: readEnd.rawValue, closeOnDealloc: true) - self.state = state - let flags = fcntl(readEnd.rawValue, F_GETFL) - guard flags >= 0, fcntl(readEnd.rawValue, F_SETFL, flags | O_NONBLOCK) >= 0 else { - throw ManagedLineProcessError.launchFailed( - "Cannot configure the managed process output pipe.") - } - } - - func start() { - arm() - } - - func stop(drainRemainingOutput: Bool) async { - let task = lock.withLock { () -> Task? in - if !stopped { - stopped = true - handle.readabilityHandler = nil - if drainRemainingOutput { - // A pipe holds fewer bytes than this budget. A concurrent writer cannot make drain infinite. - var remaining = 1_048_576 - while remaining > 0, let data = readChunkLocked(), !data.isEmpty { - remaining -= data.count - enqueueLocked(data) - } - if remaining <= 0 { - let previous = consumptionTask - consumptionTask = Task { [state] in - await previous?.value - await state.recordStreamError(ManagedLineProcessError.bufferOverflow) - } - } - } - try? handle.close() - } - return consumptionTask - } - await task?.value - } - - private func arm() { - lock.withLock { armLocked() } - } - - private func armLocked() { - guard !stopped else { return } - handle.readabilityHandler = { [weak self] handle in - self?.consumeAvailableData(from: handle) - } - } - - private func consumeAvailableData(from handle: FileHandle) { - lock.withLock { - guard !stopped else { return } - handle.readabilityHandler = nil - guard let data = readChunkLocked() else { - armLocked() - return - } - guard !data.isEmpty else { - stopped = true - try? handle.close() - return - } - enqueueLocked(data) - } - } - - private func readChunkLocked() -> Data? { - var bytes = [UInt8](repeating: 0, count: 65_536) - while true { - let count = Darwin.read(handle.fileDescriptor, &bytes, bytes.count) - if count >= 0 { return Data(bytes.prefix(count)) } - if errno == EINTR { continue } - if errno == EAGAIN || errno == EWOULDBLOCK { return nil } - let previous = consumptionTask - consumptionTask = Task { [state] in - await previous?.value - await state.recordStreamError( - ManagedLineProcessError.launchFailed("Cannot read managed process stdout.")) - } - return Data() - } - } - private func enqueueLocked(_ data: Data) { - let previous = consumptionTask - consumptionTask = Task { [weak self, state] in - await previous?.value - do { - try await state.appendStandardOutput(data) - self?.arm() - } catch { - await state.recordStreamError(error) - self?.stopProducing() + #if os(Windows) + private static func isAbsoluteWindowsPath(_ path: String) -> Bool { + let bytes = Array(path.replacingOccurrences(of: "/", with: "\\").utf8) + if bytes.starts(with: [92, 92]) { return bytes.count > 2 } + guard bytes.count >= 3, bytes[1] == 58, bytes[2] == 92 else { return false } + return (65...90).contains(bytes[0]) || (97...122).contains(bytes[0]) } - } - } - - private func stopProducing() { - lock.withLock { - guard !stopped else { return } - stopped = true - handle.readabilityHandler = nil - try? handle.close() - } - } -} + #endif -private actor ManagedLineProcessState { - private let continuation: AsyncThrowingStream.Continuation - private let maximumMessageBytes: Int - private let ownerProcessID: Int32 - private var state: ManagedLineProcessSnapshot.State = .starting - private var execution: Execution? - private var inputWriter: StandardInputWriter? - private var processID: Int32? - private var supervisorProcessID: Int32? - private var startedAt: Date? - private var stoppedAt: Date? - private var exitCode: Int32? - private var signal: Int32? - private var terminationEscalated = false - private var pendingWrites = 0 - private var lastError: String? - private var outputBuffer = Data() - private var shutdownRequested = false - private var launchFinished = false - private var readyWaiters: [CheckedContinuation] = [] - - init( - continuation: AsyncThrowingStream.Continuation, - maximumMessageBytes: Int, - ownerProcessID: Int32 - ) { - self.continuation = continuation - self.maximumMessageBytes = maximumMessageBytes - self.ownerProcessID = ownerProcessID - } - - func attach(execution: Execution, inputWriter: StandardInputWriter) { - self.execution = execution - self.inputWriter = inputWriter - supervisorProcessID = Int32(execution.processIdentifier.value) - startedAt = Date() - if shutdownRequested { - state = .stopping - failReadyWaiters(ManagedLineProcessError.closed) - } - } - - func attachChild(processID: Int32) { - self.processID = processID - guard !shutdownRequested else { return } - state = .running - let waiters = readyWaiters - readyWaiters.removeAll() - for waiter in waiters { - waiter.resume() - } - } - - func send(line: String) async throws { - try await waitUntilReady() - guard state == .running, let inputWriter else { - throw ManagedLineProcessError.closed - } - guard line.utf8.count <= maximumMessageBytes else { - throw ManagedLineProcessError.oversizedMessage(maximumMessageBytes) - } - guard !line.contains("\n") else { throw ManagedLineProcessError.invalidConfiguration } - let frame = Data((line + "\n").utf8) - pendingWrites += 1 - defer { pendingWrites -= 1 } - _ = try await inputWriter.write(Array(frame)) - } - - func appendStandardOutput(_ data: Data) throws { - guard !data.isEmpty else { return } - outputBuffer.append(data) - guard outputBuffer.count <= maximumMessageBytes || outputBuffer.contains(0x0A) else { - let error = ManagedLineProcessError.oversizedMessage(maximumMessageBytes) - continuation.finish(throwing: error) - throw error - } - - while let newline = outputBuffer.firstIndex(of: 0x0A) { - var line = outputBuffer[.. Execution? { - guard !hasFinished() else { return nil } - return execution - } - - func isShuttingDown() -> Bool { shutdownRequested } - - func canSignalOwnedProcess() -> Bool { processID != nil || hasFinished() } - - func signalChildGroup(_ signal: Int32) -> Bool { - guard let processID, processID > 1 else { return false } - return Darwin.kill(-processID, signal) == 0 || errno == ESRCH - } - - func recordTerminationEscalation() { - terminationEscalated = true - } - - func recordStreamError(_ error: Error) { - guard state != .stopped else { return } - lastError = Self.safeMessage(error) - continuation.finish(throwing: error) - } - - func finish(status: TerminationStatus) { - launchFinished = true - switch status { - case .exited(let code): - exitCode = code - case .signaled(let code): - signal = code - } - inputWriter = nil - execution = nil - stoppedAt = Date() - if state != .failed { - state = .stopped - } - failReadyWaiters(ManagedLineProcessError.closed) - continuation.finish() - } - - func failLaunch(_ error: Error) { - guard !hasFinished() else { return } - launchFinished = true - state = .failed - lastError = Self.safeMessage(error) - stoppedAt = Date() - inputWriter = nil - execution = nil - let launchError = ManagedLineProcessError.launchFailed( - Self.safeMessage(error) - ) - failReadyWaiters(launchError) - continuation.finish(throwing: launchError) - } - - func failTerminationTimeout() { - guard !hasFinished() else { return } - state = .failed - let error = ManagedLineProcessError.terminationTimedOut(processID: processID) - lastError = Self.safeMessage(error) - stoppedAt = Date() - failReadyWaiters(error) - continuation.finish(throwing: error) - } - - func hasFinished() -> Bool { - launchFinished && execution == nil - } - - func snapshot() -> ManagedLineProcessSnapshot { - ManagedLineProcessSnapshot( - state: state, - processID: processID, - supervisorProcessID: supervisorProcessID, - parentProcessID: ownerProcessID, - processGroupID: processID, - startedAt: startedAt, - stoppedAt: stoppedAt, - exitCode: exitCode, - signal: signal, - terminationEscalated: terminationEscalated, - pendingWrites: pendingWrites, - hasExited: hasFinished(), - lastError: lastError - ) - } - - private func waitUntilReady() async throws { - switch state { - case .running: - return - case .starting: - try await withCheckedThrowingContinuation { continuation in - readyWaiters.append(continuation) - } - case .stopping, .stopped: - throw ManagedLineProcessError.closed - case .failed: - throw ManagedLineProcessError.launchFailed( - lastError ?? "Unknown launch failure." - ) - } - } - - private func failReadyWaiters(_ error: Error) { - let waiters = readyWaiters - readyWaiters.removeAll() - for waiter in waiters { - waiter.resume(throwing: error) - } - } - - private static func safeMessage(_ error: Error) -> String { - // Generic I/O errors can contain argv, environment values or protocol payloads. - guard let error = error as? ManagedLineProcessError else { - return "Managed process I/O failed." - } - return error.localizedDescription } } diff --git a/Sources/CodexAdapter/ProtocolInventory.swift b/Sources/CodexAdapter/ProtocolInventory.swift index 18e08a4..8857851 100644 --- a/Sources/CodexAdapter/ProtocolInventory.swift +++ b/Sources/CodexAdapter/ProtocolInventory.swift @@ -15,10 +15,23 @@ struct ProtocolInventory: Sendable { let messages: Int } let codexVersion: String + let adoptionSHA256: String let files: [String: File] } + struct Adoption: Decodable, Sendable { + struct Exclusion: Decodable, Sendable { + let method: String + let reason: String + } + let schema: String + let upstreamTag: String + let adopted: [String: [String]] + let excluded: [Exclusion] + } + let receipt: Receipt + let adoption: Adoption private let schemas: [String: AppServerSchema] init(directory: URL) throws { @@ -47,6 +60,27 @@ struct ProtocolInventory: Sendable { } } self.schemas = schemas + let adoptionData = try Self.read( + directory.appendingPathComponent("adoption.json"), limit: 65_536) + guard AppServerSchema.digest(adoptionData) == receipt.adoptionSHA256 else { + throw SchemaError.invalid("SDK adoption integrity mismatch.") + } + adoption = try JSONDecoder().decode(Adoption.self, from: adoptionData) + guard adoption.schema == "swift-codex.codex-app-server-method-adoption.v1", + adoption.upstreamTag == "rust-v\(receipt.codexVersion)", + Set(adoption.adopted.keys) == Set(Channel.allCases.map(\.rawValue)) + else { throw SchemaError.invalid("Invalid SDK adoption metadata.") } + var adopted = Set() + for channel in Channel.allCases { + for method in adoption.adopted[channel.rawValue] ?? [] { + guard adopted.insert(method).inserted, + schemas[Self.filename(channel, .clientRequest)]?.message(named: method) != nil + else { throw SchemaError.invalid("Invalid adopted request: \(method).") } + } + } + guard Set(adoption.excluded.map(\.method)).isDisjoint(with: adopted) else { + throw SchemaError.invalid("An SDK request cannot be adopted and excluded.") + } } static func bundled() throws -> Self { diff --git a/Sources/CodexAdapter/ProtocolTools.swift b/Sources/CodexAdapter/ProtocolTools.swift index 3a1cbfa..817626d 100644 --- a/Sources/CodexAdapter/ProtocolTools.swift +++ b/Sources/CodexAdapter/ProtocolTools.swift @@ -84,7 +84,7 @@ struct ProtocolTools: Sendable { let text = String(decoding: try encoder.encode(value), as: UTF8.self) return MCP.CallTool.Result( content: [.text(text: text, annotations: nil, _meta: nil)], - structuredContent: Optional.some(Self.mcpValue(value)), + structuredContent: Optional.some(try Self.mcpValue(value)), isError: false) } @@ -120,15 +120,8 @@ struct ProtocolTools: Sendable { ]) } - static func mcpValue(_ value: AppServerJSON) -> MCP.Value { - switch value { - case .null: .null - case .bool(let value): .bool(value) - case .number(.integer(let value)): .int(Int(value)) - case .number(.decimal(let value)): .double(NSDecimalNumber(decimal: value).doubleValue) - case .string(let value): .string(value) - case .array(let values): .array(values.map(mcpValue)) - case .object(let values): .object(values.mapValues(mcpValue)) - } + static func mcpValue(_ value: AppServerJSON) throws -> MCP.Value { + let json = try JSONValue.encoded(value) + return try JSONDecoder().decode(MCP.Value.self, from: JSONEncoder().encode(json)) } } diff --git a/Sources/CodexAdapter/Resources/Protocol/adoption.json b/Sources/CodexAdapter/Resources/Protocol/adoption.json new file mode 100644 index 0000000..58ee5d3 --- /dev/null +++ b/Sources/CodexAdapter/Resources/Protocol/adoption.json @@ -0,0 +1,176 @@ +{ + "schema": "swift-codex.codex-app-server-method-adoption.v1", + "upstreamTag": "rust-v0.154.0", + "adopted": { + "stable": [ + "account/login/cancel", + "account/login/start", + "account/logout", + "account/rateLimitResetCredit/consume", + "account/rateLimits/read", + "account/read", + "account/sendAddCreditsNudgeEmail", + "account/usage/read", + "account/workspaceMessages/read", + "app/installed", + "app/list", + "app/read", + "command/exec", + "command/exec/resize", + "command/exec/terminate", + "command/exec/write", + "config/batchWrite", + "config/mcpServer/reload", + "config/read", + "config/value/write", + "configRequirements/read", + "experimentalFeature/enablement/set", + "experimentalFeature/list", + "externalAgentConfig/detect", + "externalAgentConfig/import", + "externalAgentConfig/import/readHistories", + "externalAgentConfig/import/recordHistory", + "feedback/upload", + "fs/copy", + "fs/createDirectory", + "fs/getMetadata", + "fs/readDirectory", + "fs/readFile", + "fs/remove", + "fs/unwatch", + "fs/watch", + "fs/writeFile", + "hooks/list", + "marketplace/add", + "marketplace/remove", + "marketplace/upgrade", + "mcpServer/oauth/login", + "mcpServer/resource/read", + "mcpServer/tool/call", + "mcpServerStatus/list", + "model/list", + "modelProvider/capabilities/read", + "permissionProfile/list", + "plugin/install", + "plugin/installed", + "plugin/list", + "plugin/read", + "plugin/share/checkout", + "plugin/share/delete", + "plugin/share/list", + "plugin/share/save", + "plugin/share/updateTargets", + "plugin/skill/read", + "plugin/uninstall", + "review/start", + "skills/config/write", + "skills/extraRoots/set", + "skills/list", + "thread/approveGuardianDeniedAction", + "thread/archive", + "thread/compact/start", + "thread/delete", + "thread/fork", + "thread/goal/clear", + "thread/goal/get", + "thread/goal/set", + "thread/inject_items", + "thread/items/list", + "thread/list", + "thread/loaded/list", + "thread/metadata/update", + "thread/name/set", + "thread/read", + "thread/resume", + "thread/revert", + "thread/rollback", + "thread/section/move", + "thread/shellCommand", + "thread/start", + "thread/turns/list", + "thread/unarchive", + "thread/unsubscribe", + "threadSection/create", + "threadSection/delete", + "threadSection/list", + "threadSection/update", + "turn/interrupt", + "turn/start", + "turn/steer", + "windowsSandbox/readiness", + "windowsSandbox/setupStart" + ], + "experimental": [ + "collaborationMode/list", + "environment/add", + "environment/info", + "environment/status", + "mcpServer/event/stream/start", + "mcpServer/event/stream/stop", + "memory/reset", + "mock/experimentalMethod", + "plugin/search", + "process/kill", + "process/resizePty", + "process/spawn", + "process/writeStdin", + "project/create", + "project/delete", + "project/import", + "project/list", + "project/move", + "project/read", + "project/update", + "remoteControl/client/list", + "remoteControl/client/revoke", + "remoteControl/disable", + "remoteControl/enable", + "remoteControl/pairing/start", + "remoteControl/pairing/status", + "remoteControl/status/read", + "server/diagnostics", + "thread/backgroundTerminals/clean", + "thread/backgroundTerminals/list", + "thread/backgroundTerminals/terminate", + "thread/decrement_elicitation", + "thread/increment_elicitation", + "thread/memoryMode/set", + "thread/queue/add", + "thread/queue/delete", + "thread/queue/list", + "thread/queue/reorder", + "thread/queue/start", + "thread/queue/update", + "thread/realtime/appendAudio", + "thread/realtime/appendSpeech", + "thread/realtime/appendText", + "thread/realtime/listVoices", + "thread/realtime/start", + "thread/realtime/stop", + "thread/search", + "thread/searchOccurrences", + "thread/settings/update", + "thread/timeline/list", + "turn/settings/update" + ] + }, + "excluded": [ + { "method": "FuzzyFileSearch", "reason": "legacy method is not adopted" }, + { "method": "GetAuthStatus", "reason": "legacy method is not adopted" }, + { "method": "GetConversationSummary", "reason": "legacy method is not adopted" }, + { "method": "GitDiffToRemote", "reason": "legacy method is not adopted" }, + { "method": "account/bedrock/discover", "reason": "Bedrock account onboarding is not adopted" }, + { "method": "account/bedrock/setup", "reason": "Bedrock account onboarding is not adopted" }, + { "method": "fuzzyFileSearch", "reason": "deprecated upstream method is not adopted" }, + { "method": "fuzzyFileSearch/sessionStart", "reason": "experimental fuzzy session is not adopted" }, + { "method": "fuzzyFileSearch/sessionStop", "reason": "experimental fuzzy session is not adopted" }, + { "method": "fuzzyFileSearch/sessionUpdate", "reason": "experimental fuzzy session is not adopted" }, + { "method": "initialize", "reason": "connection lifecycle owns the handshake" }, + { "method": "initialized", "reason": "connection lifecycle owns the handshake" }, + { "method": "plugin/reconcile", "reason": "plugin reconciliation is not adopted" }, + { "method": "userVerification/delete", "reason": "user verification administration is not adopted" }, + { "method": "userVerification/enroll", "reason": "user verification administration is not adopted" }, + { "method": "userVerification/status", "reason": "user verification administration is not adopted" }, + { "method": "userVerification/verify", "reason": "user verification administration is not adopted" } + ] +} diff --git a/Sources/CodexAdapter/Resources/Protocol/receipt.json b/Sources/CodexAdapter/Resources/Protocol/receipt.json index 181f963..5c63d29 100644 --- a/Sources/CodexAdapter/Resources/Protocol/receipt.json +++ b/Sources/CodexAdapter/Resources/Protocol/receipt.json @@ -1,5 +1,14 @@ { "codexVersion": "0.154.0", + "sdk": { + "version": "0.4.1", + "revision": "35e4a3dc07e60f084efb8a06424a8315c2ab6f64" + }, + "upstream": { + "tag": "rust-v0.154.0", + "commit": "6b9826e3aa83b1a5947db50f4332cb9c65f1b340" + }, + "adoptionSHA256": "a4b439ce69cb5993b2907c480fe7caf338a6e15dab2892746c0ad320cff794c3", "files": { "stable/ClientRequest.json": { "sha256": "da767fedd73502ceb644f5d7974927e1a6b7f1917b092aaa029e81949abf0125", diff --git a/Sources/CodexAdapter/WindowsCommandProcess.swift b/Sources/CodexAdapter/WindowsCommandProcess.swift new file mode 100644 index 0000000..19cb30d --- /dev/null +++ b/Sources/CodexAdapter/WindowsCommandProcess.swift @@ -0,0 +1,223 @@ +#if os(Windows) + import Dispatch + import Foundation + import Synchronization + import WinSDK + + /// Owns the root process and the parent ends of its two output pipes. + final class WindowsCommandProcess: Sendable { + struct Capture: Sendable { + var data = Data() + var truncated = false + } + + // HANDLE is an opaque kernel token. Only this owner's mutex guards access + // and closing; synchronous Job admission borrows the tokens without retaining them. + private struct Handles: @unchecked Sendable { + let process: HANDLE + let thread: HANDLE + } + + private let handles: Mutex + let stdout: WindowsCommandPipe + let stderr: WindowsCommandPipe + + init( + path: String, arguments: [String], cwd: String, environment: [String: String], + job: WindowsProcessJob + ) throws { + var command = Array(([path] + arguments).map(Self.quote).joined(separator: " ").utf16) + [0] + guard command.count <= 32_767 else { + throw CommandRunnerError.launchFailed("Command line exceeds the Windows launch limit.") + } + let output = try Self.pipe() + defer { CloseHandle(output.write) } + let stdout = WindowsCommandPipe(output.read) + let error = try Self.pipe() + defer { CloseHandle(error.write) } + let stderr = WindowsCommandPipe(error.read) + let input = try Self.pipe() + // Closing the only writer supplies EOF without inheriting the host's stdin. + CloseHandle(input.write) + defer { CloseHandle(input.read) } + guard SetHandleInformation(input.read, DWORD(HANDLE_FLAG_INHERIT), DWORD(HANDLE_FLAG_INHERIT)) + else { throw Self.error("SetHandleInformation") } + + var environmentBlock = + Array( + environment.sorted { WindowsProcessEnvironment.isOrderedBefore($0.key, $1.key) } + .map { "\($0.key)=\($0.value)" }.joined(separator: "\0").utf16) + [0, 0] + var info = STARTUPINFOEXW() + info.StartupInfo.cb = DWORD(MemoryLayout.size) + info.StartupInfo.dwFlags = DWORD(STARTF_USESTDHANDLES) | DWORD(STARTF_USESHOWWINDOW) + info.StartupInfo.wShowWindow = WORD(SW_HIDE) + info.StartupInfo.hStdInput = input.read + info.StartupInfo.hStdOutput = output.write + info.StartupInfo.hStdError = error.write + + var attributeBytes: SIZE_T = 0 + _ = InitializeProcThreadAttributeList(nil, 1, 0, &attributeBytes) + guard attributeBytes > 0 else { throw Self.error("InitializeProcThreadAttributeList") } + let storage = UnsafeMutableRawPointer.allocate(byteCount: Int(attributeBytes), alignment: 16) + defer { storage.deallocate() } + let attributes = LPPROC_THREAD_ATTRIBUTE_LIST(storage) + guard InitializeProcThreadAttributeList(attributes, 1, 0, &attributeBytes) else { + throw Self.error("InitializeProcThreadAttributeList") + } + var inherited = [input.read, output.write, error.write] + var created = PROCESS_INFORMATION() + try inherited.withUnsafeMutableBufferPointer { list in + defer { DeleteProcThreadAttributeList(attributes) } + // PROC_THREAD_ATTRIBUTE_HANDLE_LIST: attribute 2, input flag. The SDK macro + // uses a C expression that is not imported by Swift. + guard + UpdateProcThreadAttribute( + attributes, 0, 0x0002_0002, list.baseAddress, + SIZE_T(list.count * MemoryLayout.stride), nil, nil) + else { throw Self.error("UpdateProcThreadAttribute") } + info.lpAttributeList = attributes + let launched = path.withCString(encodedAs: UTF16.self) { executable in + cwd.withCString(encodedAs: UTF16.self) { directory in + environmentBlock.withUnsafeMutableBufferPointer { environment in + withUnsafeMutablePointer(to: &info) { extended in + extended.withMemoryRebound(to: STARTUPINFOW.self, capacity: 1) { startup in + CreateProcessW( + executable, &command, nil, nil, true, + DWORD( + CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT), + environment.baseAddress, directory, startup, &created) + } + } + } + } + } + guard launched else { throw Self.error("CreateProcessW") } + } + handles = Mutex(Handles(process: created.hProcess!, thread: created.hThread!)) + self.stdout = stdout + self.stderr = stderr + job.start(process: created.hProcess!, thread: created.hThread!) + } + + deinit { + handles.withLock { + CloseHandle($0.thread) + CloseHandle($0.process) + } + } + + func wait(job: WindowsProcessJob) async -> Result { + while true { + let status = handles.withLock { WaitForSingleObject($0.process, 0) } + switch status { + case DWORD(WAIT_OBJECT_0): + job.stop(.exited) + return handles.withLock { + var code: DWORD = 0 + guard GetExitCodeProcess($0.process, &code) else { + return .failure(Self.error("GetExitCodeProcess")) + } + return .success(Int32(bitPattern: code)) + } + case DWORD(WAIT_TIMEOUT): + try? await Task.sleep(for: .milliseconds(10)) + default: + let error = Self.error("WaitForSingleObject") + job.stop(.failed) + return .failure(error) + } + } + } + + private static func pipe() throws -> (read: HANDLE, write: HANDLE) { + var security = SECURITY_ATTRIBUTES() + security.nLength = DWORD(MemoryLayout.size) + security.bInheritHandle = true + var read: HANDLE? + var write: HANDLE? + guard CreatePipe(&read, &write, &security, 0), let read, let write else { + throw error("CreatePipe") + } + guard SetHandleInformation(read, DWORD(HANDLE_FLAG_INHERIT), 0) else { + let failure = error("SetHandleInformation") + CloseHandle(read) + CloseHandle(write) + throw failure + } + return (read, write) + } + + /// Encodes one CRT argument, including empty values and trailing backslashes. + private static func quote(_ argument: String) -> String { + if !argument.isEmpty, !argument.contains(where: { " \t\r\n\"".contains($0) }) { + return argument + } + var result = "\"" + var slashes = 0 + for character in argument.unicodeScalars { + if character == "\\" { + slashes += 1 + } else { + result += String(repeating: "\\", count: character == "\"" ? slashes * 2 + 1 : slashes) + result.unicodeScalars.append(character) + slashes = 0 + } + } + return result + String(repeating: "\\", count: slashes * 2) + "\"" + } + + static func error(_ operation: String, code: DWORD = GetLastError()) -> CommandRunnerError { + .launchFailed("\(operation) failed (Windows error \(code)).") + } + } + + /// A blocking reader runs on a dispatch worker and retains its handle until EOF. + final class WindowsCommandPipe: Sendable { + // The native token is never dereferenced. The mutex guards the reader's + // exclusive ownership through ReadFile and CloseHandle. + private struct State: @unchecked Sendable { + var handle: HANDLE? + } + + private let state: Mutex + private let reader = DispatchQueue(label: "codex-adapter.command.output", qos: .utility) + + init(_ handle: HANDLE) { state = Mutex(State(handle: handle)) } + deinit { state.withLock { if let value = $0.handle { CloseHandle(value) } } } + + func capture( + limit: Int, job: WindowsProcessJob + ) async -> Result { + await withCheckedContinuation { continuation in + reader.async { + let result = self.state.withLock { + state -> Result in + guard let pipe = state.handle else { + return .failure(.launchFailed("Command output pipe is closed.")) + } + defer { + CloseHandle(pipe) + state.handle = nil + } + var captured = WindowsCommandProcess.Capture() + var buffer = [UInt8](repeating: 0, count: 16_384) + while true { + var count: DWORD = 0 + guard ReadFile(pipe, &buffer, DWORD(buffer.count), &count, nil) else { + let code = GetLastError() + if code == DWORD(ERROR_BROKEN_PIPE) { return .success(captured) } + return .failure(WindowsCommandProcess.error("ReadFile", code: code)) + } + if count == 0 { return .success(captured) } + let retained = min(Int(count), limit - captured.data.count) + captured.data.append(contentsOf: buffer.prefix(retained)) + captured.truncated = captured.truncated || retained < Int(count) + } + } + if case .failure = result { job.stop(.failed) } + continuation.resume(returning: result) + } + } + } + } +#endif diff --git a/Sources/CodexAdapter/WindowsExecutable.swift b/Sources/CodexAdapter/WindowsExecutable.swift new file mode 100644 index 0000000..e83d075 --- /dev/null +++ b/Sources/CodexAdapter/WindowsExecutable.swift @@ -0,0 +1,49 @@ +#if os(Windows) + import Foundation + + enum WindowsExecutable { + static func resolve(_ executable: String, workspace: URL, environment: [String: String]) throws + -> URL + { + guard let directory = WindowsFilePath.native(workspace), + WindowsFilePath.isValid(directory), WindowsFilePath.isAbsolute(directory), + WindowsFilePath.isValid(executable) + else { + throw CommandRunnerError.launchFailed("Invalid Windows executable or working directory.") + } + let candidates: [String] + if executable.contains(where: { "/\\:".contains($0) }) { + candidates = [executable] + } else { + let entries = environment.filter { WindowsProcessEnvironment.namesMatch($0.key, "PATH") } + guard entries.count <= 1, entries.first?.value.utf16.contains(0) != true else { + throw CommandRunnerError.launchFailed("Invalid or ambiguous Windows PATH.") + } + let name = executable.lowercased().hasSuffix(".exe") ? executable : executable + ".exe" + candidates = (entries.first?.value ?? "").split(separator: ";").compactMap { entry in + var path = String(entry) + if path.hasPrefix("\""), path.hasSuffix("\""), path.count >= 2 { + path.removeFirst() + path.removeLast() + } + return path.isEmpty ? nil : path + "\\" + name + } + } + for candidate in candidates { + guard let path = WindowsFilePath.absolute(candidate, cwd: directory) else { + throw CommandRunnerError.launchFailed( + "Executable paths must be native absolute or workspace-relative paths.") + } + let url = URL(fileURLWithPath: path) + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + !isDirectory.boolValue, FileManager.default.isExecutableFile(atPath: url.path) + { + return url + } + } + throw CommandRunnerError.launchFailed( + "Cannot resolve executable in the launch workspace and PATH.") + } + } +#endif diff --git a/Sources/CodexAdapter/WindowsFilePath.swift b/Sources/CodexAdapter/WindowsFilePath.swift new file mode 100644 index 0000000..c69b81f --- /dev/null +++ b/Sources/CodexAdapter/WindowsFilePath.swift @@ -0,0 +1,65 @@ +#if os(Windows) + import Foundation + import WinSDK + + /// Win32 filesystem names; device namespaces and alternate streams are not paths here. + enum WindowsFilePath { + static func native(_ url: URL) -> String? { + guard url.isFileURL else { return nil } + return url.withUnsafeFileSystemRepresentation { $0.map(String.init(cString:)) } + } + + static func isAbsolute(_ value: String) -> Bool { + let path = value.replacingOccurrences(of: "/", with: "\\") + let bytes = Array(path.utf8.prefix(3)) + return path.hasPrefix("\\\\") + || (bytes.count == 3 && isDriveLetter(bytes[0]) && bytes[1] == 58 && bytes[2] == 92) + } + + static func isDriveLetter(_ value: UInt8) -> Bool { + (65...90).contains(value) || (97...122).contains(value) + } + + static func isValid(_ value: String) -> Bool { + guard !value.isEmpty, value.utf16.count < 32_767, + !value.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) + else { return false } + let path = value.replacingOccurrences(of: "/", with: "\\") + guard !path.hasPrefix("\\\\?\\"), !path.hasPrefix("\\\\.\\"), !path.hasPrefix("\\??\\"), + !path.contains(where: { "<>\"|?*".contains($0) }) + else { return false } + let bytes = Array(path.utf8) + let withoutDrive = + bytes.count >= 3 && isDriveLetter(bytes[0]) && bytes[1] == 58 && bytes[2] == 92 + ? String(path.dropFirst(2)) : path + guard !withoutDrive.contains(":") else { return false } + let components = withoutDrive.split(separator: "\\") + if path.hasPrefix("\\\\") { + guard components.count >= 2, + !components.prefix(2).contains(where: { $0 == "." || $0 == ".." }), + !["pipe", "mailslot"].contains(components[1].lowercased()) + else { return false } + } + return components.allSatisfy { component in + if component == "." || component == ".." { return true } + guard component.last != ".", component.last != " " else { return false } + let stem = component.split(separator: ".", maxSplits: 1).first?.uppercased() ?? "" + return !["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"].contains(stem) + && !["COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"].contains(stem) + && !(1...9).contains(where: { stem == "COM\($0)" || stem == "LPT\($0)" }) + } + } + + static func absolute(_ value: String, cwd: String) -> String? { + guard isValid(value) else { return nil } + let path = value.replacingOccurrences(of: "/", with: "\\") + guard !path.hasPrefix("\\") || path.hasPrefix("\\\\") else { return nil } + let joined = isAbsolute(path) ? path : cwd + "\\" + path + guard joined.utf16.count < 32_767 else { return nil } + var output = [WCHAR](repeating: 0, count: 32_768) + let length = GetFullPathNameW(Array(joined.utf16) + [0], DWORD(output.count), &output, nil) + guard length > 0, length < output.count else { return nil } + return String(decoding: output.prefix(Int(length)), as: UTF16.self) + } + } +#endif diff --git a/Sources/CodexAdapter/WindowsPrivateDirectory.swift b/Sources/CodexAdapter/WindowsPrivateDirectory.swift new file mode 100644 index 0000000..1c76f46 --- /dev/null +++ b/Sources/CodexAdapter/WindowsPrivateDirectory.swift @@ -0,0 +1,322 @@ +#if os(Windows) + import Foundation + import WinSDK + + enum WindowsPrivateDirectoryError: Error, LocalizedError, Sendable { + case invalid(String) + case native(String, DWORD) + + var errorDescription: String? { + switch self { + case .invalid(let message): message + case .native(let operation, let code): "\(operation) failed (Windows error \(code))." + } + } + } + + /// Keeps the private directory and its ancestry stable for its consumer's lifetime. + final class WindowsPrivateDirectory: @unchecked Sendable { + // These non-inherited kernel handles are immutable, never exposed and closed only on deinit. + private let handles: [HANDLE] + private let security: PrivateSecurity + private let ancestor: WindowsPrivateDirectory? + private let requiresPrivateDACL: Bool + let url: URL + + convenience init(_ url: URL) throws { + try self.init(url, createMissing: true, ancestor: nil, requiresPrivateDACL: true) + } + + convenience init(existingDirectory url: URL) throws { + try self.init(url, createMissing: false, ancestor: nil, requiresPrivateDACL: true) + } + + convenience init(existingDirectory url: URL, containedIn root: URL) throws { + try self.init( + url, createMissing: false, ancestor: WindowsPrivateDirectory(existingDirectory: root), + requiresPrivateDACL: false) + } + + convenience init(creatingDirectory url: URL, containedIn root: URL) throws { + try self.init( + url, createMissing: true, ancestor: WindowsPrivateDirectory(existingDirectory: root), + requiresPrivateDACL: true) + } + + private init( + _ url: URL, createMissing: Bool, ancestor: WindowsPrivateDirectory?, + requiresPrivateDACL: Bool + ) throws { + guard let native = WindowsFilePath.native(url), WindowsFilePath.isAbsolute(native), + let path = WindowsFilePath.absolute(native, cwd: native) + else { + throw WindowsPrivateDirectoryError.invalid( + "Private state requires a native absolute directory.") + } + let prefixes = try Self.ancestors(path) + let security = try PrivateSecurity() + let ancestorIdentity = try ancestor.map { try WindowsDirectoryIdentity($0.handles.last!) } + var foundAncestor = ancestor == nil + var retained: [HANDLE] = [] + do { + for (index, prefix) in prefixes.enumerated() { + let final = index == prefixes.count - 1 + var inspectSecurity = final && requiresPrivateDACL + var handle = Self.open(prefix, inspectSecurity: inspectSecurity) + if handle == nil || handle == INVALID_HANDLE_VALUE { + let code = GetLastError() + guard createMissing, foundAncestor, index > 0, + code == DWORD(ERROR_FILE_NOT_FOUND) || code == DWORD(ERROR_PATH_NOT_FOUND) + else { + throw WindowsPrivateDirectoryError.native("Open private directory ancestry", code) + } + var attributes = SECURITY_ATTRIBUTES() + attributes.nLength = DWORD(MemoryLayout.size) + attributes.lpSecurityDescriptor = security.descriptor + attributes.bInheritHandle = false + if !CreateDirectoryW(Array(prefix.utf16) + [0], &attributes) { + let code = GetLastError() + guard code == DWORD(ERROR_ALREADY_EXISTS) else { + throw WindowsPrivateDirectoryError.native("Create private directory", code) + } + } + inspectSecurity = true + handle = Self.open(prefix, inspectSecurity: true) + } + guard let handle, handle != INVALID_HANDLE_VALUE else { + throw WindowsPrivateDirectoryError.native("Open private directory", GetLastError()) + } + retained.append(handle) + try Self.validateDirectory(handle) + if inspectSecurity { try security.validate(handle) } + if let ancestorIdentity, try WindowsDirectoryIdentity(handle) == ancestorIdentity { + foundAncestor = true + } + } + guard foundAncestor else { + throw WindowsPrivateDirectoryError.invalid( + "Managed directory ancestry does not contain the owned private root.") + + } + } catch { + for handle in retained.reversed() { CloseHandle(handle) } + throw error + } + handles = retained + self.security = security + self.ancestor = ancestor + self.requiresPrivateDACL = requiresPrivateDACL + self.url = URL(fileURLWithPath: path, isDirectory: true) + } + + deinit { for handle in handles.reversed() { CloseHandle(handle) } } + + func validate() throws { + for handle in handles { try Self.validateDirectory(handle) } + try ancestor?.validate() + if requiresPrivateDACL { try security.validate(handles[handles.count - 1]) } + } + + private static func open(_ path: String, inspectSecurity: Bool) -> HANDLE? { + // Attribute-only handles do not participate in data/delete sharing checks. + // Directory read access makes denial of rename/delete effective for this handle's lifetime. + CreateFileW( + Array(path.utf16) + [0], + DWORD(FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES) + | (inspectSecurity ? DWORD(READ_CONTROL) : 0), + DWORD(FILE_SHARE_READ), nil, DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT), nil) + } + + private static func validateDirectory(_ handle: HANDLE) throws { + var information = BY_HANDLE_FILE_INFORMATION() + guard GetFileType(handle) == DWORD(FILE_TYPE_DISK), + GetFileInformationByHandle(handle, &information) + else { + throw WindowsPrivateDirectoryError.native("Inspect private directory", GetLastError()) + } + guard information.dwFileAttributes & DWORD(FILE_ATTRIBUTE_DIRECTORY) != 0, + information.dwFileAttributes & DWORD(FILE_ATTRIBUTE_REPARSE_POINT) == 0 + else { + throw WindowsPrivateDirectoryError.invalid( + "Private state paths must be real directories without reparse points.") + } + } + + private static func ancestors(_ path: String) throws -> [String] { + let components = path.split(separator: "\\") + var current: String + let suffix: ArraySlice + if path.hasPrefix("\\\\") { + guard components.count >= 3 else { + throw WindowsPrivateDirectoryError.invalid( + "A network share root cannot be private adapter state.") + } + current = "\\\\" + components[0] + "\\" + components[1] + suffix = components.dropFirst(2) + } else { + guard components.count >= 2 else { + throw WindowsPrivateDirectoryError.invalid( + "A volume root cannot be private adapter state.") + } + current = String(path.prefix(3)) + suffix = components.dropFirst() + } + guard components.count <= 1_024 else { + throw WindowsPrivateDirectoryError.invalid( + "Private directory ancestry exceeds its inspection bound.") + } + var result = [current] + for component in suffix { + current += (current.hasSuffix("\\") ? "" : "\\") + component + result.append(current) + } + return result + } + + private final class PrivateSecurity { + // FILE_ALL_ACCESS from winnt.h; Swift cannot import its mixed-type C expression. + private static let fileAllAccess = + DWORD(STANDARD_RIGHTS_REQUIRED) | DWORD(SYNCHRONIZE) | 0x1FF + let descriptor: PSECURITY_DESCRIPTOR + private let owner: PSID + + init() throws { + let sid = try Self.currentUserSID() + let text = "O:\(sid)D:P(A;OICI;FA;;;\(sid))" + var value: PSECURITY_DESCRIPTOR? + guard + ConvertStringSecurityDescriptorToSecurityDescriptorW( + Array(text.utf16) + [0], DWORD(SDDL_REVISION_1), &value, nil), let value + else { + throw WindowsPrivateDirectoryError.native( + "Create private security descriptor", GetLastError()) + } + var owner: PSID? + var defaulted: WindowsBool = false + guard GetSecurityDescriptorOwner(value, &owner, &defaulted), let owner, IsValidSid(owner) + else { + let code = GetLastError() + LocalFree(value) + throw WindowsPrivateDirectoryError.native("Inspect private security owner", code) + } + descriptor = value + self.owner = owner + } + + deinit { LocalFree(descriptor) } + + func validate(_ handle: HANDLE) throws { + var flags: DWORD = 0 + guard GetVolumeInformationByHandleW(handle, nil, 0, nil, nil, &flags, nil, 0), + flags & DWORD(FILE_PERSISTENT_ACLS) != 0 + else { + throw WindowsPrivateDirectoryError.invalid( + "Private state requires a filesystem with persistent access controls.") + } + var actualOwner: PSID? + var acl: PACL? + var actual: PSECURITY_DESCRIPTOR? + let result = GetSecurityInfo( + handle, SE_FILE_OBJECT, + DWORD(OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION), &actualOwner, nil, &acl, + nil, &actual) + guard result == DWORD(ERROR_SUCCESS), let actual else { + throw WindowsPrivateDirectoryError.native("Read private directory security", result) + } + defer { LocalFree(actual) } + guard let actualOwner, IsValidSid(actualOwner), EqualSid(actualOwner, owner) else { + throw WindowsPrivateDirectoryError.invalid( + "Private state directory belongs to a different or unverifiable Windows user.") + } + var control: SECURITY_DESCRIPTOR_CONTROL = 0 + var revision: DWORD = 0 + guard GetSecurityDescriptorControl(actual, &control, &revision), + control & SECURITY_DESCRIPTOR_CONTROL(SE_DACL_PROTECTED) != 0, + let acl, IsValidAcl(acl), acl.pointee.AceCount == 1 + else { + throw WindowsPrivateDirectoryError.invalid( + "Existing state directory is not protected by the adapter's private DACL; it was left unchanged." + ) + } + var entry: LPVOID? + guard GetAce(acl, 0, &entry), let entry else { + throw WindowsPrivateDirectoryError.native( + "Read private directory access entry", GetLastError()) + } + let allowed = entry.assumingMemoryBound(to: ACCESS_ALLOWED_ACE.self) + let header = allowed.pointee.Header + guard header.AceType == BYTE(ACCESS_ALLOWED_ACE_TYPE), + header.AceFlags == BYTE(OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE), + allowed.pointee.Mask == Self.fileAllAccess, + Int(header.AceSize) >= MemoryLayout.size + MemoryLayout.size + else { + throw WindowsPrivateDirectoryError.invalid( + "Existing state directory has incompatible access entries; it was left unchanged.") + } + let sid = entry.advanced(by: MemoryLayout.offset(of: \.SidStart)!) + guard IsValidSid(sid), + Int(GetLengthSid(sid)) <= Int(header.AceSize) - MemoryLayout.offset( + of: \.SidStart)!, + EqualSid(sid, owner) + else { + throw WindowsPrivateDirectoryError.invalid( + "Existing state directory grants access to another principal; it was left unchanged.") + } + } + + private static func currentUserSID() throws -> String { + var token: HANDLE? + if !OpenThreadToken(GetCurrentThread(), DWORD(TOKEN_QUERY), true, &token) { + let code = GetLastError() + guard code == DWORD(ERROR_NO_TOKEN) else { + throw WindowsPrivateDirectoryError.native("Open effective user token", code) + } + guard OpenProcessToken(GetCurrentProcess(), DWORD(TOKEN_QUERY), &token) else { + throw WindowsPrivateDirectoryError.native("Open process user token", GetLastError()) + } + } + guard let token else { + throw WindowsPrivateDirectoryError.invalid("Windows user token is unavailable.") + } + defer { CloseHandle(token) } + var count: DWORD = 0 + _ = GetTokenInformation(token, TokenUser, nil, 0, &count) + guard count >= MemoryLayout.size, count <= 4_096 else { + throw WindowsPrivateDirectoryError.invalid("Windows user token has an invalid size.") + } + let storage = UnsafeMutableRawPointer.allocate( + byteCount: Int(count), alignment: MemoryLayout.alignment) + defer { storage.deallocate() } + guard GetTokenInformation(token, TokenUser, storage, count, &count), + let sid = storage.assumingMemoryBound(to: TOKEN_USER.self).pointee.User.Sid, + IsValidSid(sid) + else { + throw WindowsPrivateDirectoryError.native("Read Windows user identity", GetLastError()) + } + var text: LPWSTR? + guard ConvertSidToStringSidW(sid, &text), let text else { + throw WindowsPrivateDirectoryError.native("Encode Windows user identity", GetLastError()) + } + defer { LocalFree(text) } + return String(decodingCString: text, as: UTF16.self) + } + } + } + struct WindowsDirectoryIdentity: Equatable { + private let volume: UInt64 + private let fileID: Data + + init(_ handle: HANDLE) throws { + var information = FILE_ID_INFO() + guard + GetFileInformationByHandleEx( + handle, FileIdInfo, &information, DWORD(MemoryLayout.size)) + else { + throw WindowsPrivateDirectoryError.native("Read directory identity", GetLastError()) + } + volume = information.VolumeSerialNumber + fileID = withUnsafeBytes(of: information.FileId.Identifier) { Data($0) } + } + } +#endif diff --git a/Sources/CodexAdapter/WindowsProcessEnvironment.swift b/Sources/CodexAdapter/WindowsProcessEnvironment.swift new file mode 100644 index 0000000..32c0360 --- /dev/null +++ b/Sources/CodexAdapter/WindowsProcessEnvironment.swift @@ -0,0 +1,54 @@ +#if os(Windows) + import Foundation + import WinSDK + + enum WindowsProcessEnvironment { + static func namesMatch(_ lhs: String, _ rhs: String) -> Bool { + compare(lhs, rhs) == CSTR_EQUAL + } + + static func isOrderedBefore(_ lhs: String, _ rhs: String) -> Bool { + compare(lhs, rhs) == CSTR_LESS_THAN + } + + private static func compare(_ lhs: String, _ rhs: String) -> Int32 { + let left = Array(lhs.utf16) + let right = Array(rhs.utf16) + guard !left.isEmpty, !right.isEmpty, left.count <= Int32.max, right.count <= Int32.max else { + return lhs == rhs ? CSTR_EQUAL : (lhs < rhs ? CSTR_LESS_THAN : CSTR_GREATER_THAN) + } + return left.withUnsafeBufferPointer { l in + right.withUnsafeBufferPointer { r in + CompareStringOrdinal(l.baseAddress, Int32(l.count), r.baseAddress, Int32(r.count), true) + } + } + } + + static func merging(_ base: [String: String], overrides: [String: String]) throws -> [String: + String] + { + var result: [String: String] = [:] + for (index, input) in [base, overrides].enumerated() { + var admitted: [String] = [] + for (key, value) in input { + let units = Array(key.utf16) + // Win32 may inherit drive-specific current directories such as =C:. + let inheritedDrive = + index == 0 && units.count == 3 && units[0] == 61 + && ((65...90).contains(units[1]) || (97...122).contains(units[1])) && units[2] == 58 + guard !key.isEmpty, !units.contains(0), !value.utf16.contains(0), + inheritedDrive || !key.contains("="), + !admitted.contains(where: { namesMatch($0, key) }) + else { throw CommandRunnerError.launchFailed("Invalid or ambiguous child environment.") } + admitted.append(key) + if let previous = result.keys.first(where: { namesMatch($0, key) }) { + result.removeValue(forKey: previous) + } + result[key] = value + } + } + if !result.keys.contains(where: { namesMatch($0, "PATH") }) { result["PATH"] = "" } + return result + } + } +#endif diff --git a/Sources/CodexAdapter/WindowsProcessJob.swift b/Sources/CodexAdapter/WindowsProcessJob.swift new file mode 100644 index 0000000..e1b359a --- /dev/null +++ b/Sources/CodexAdapter/WindowsProcessJob.swift @@ -0,0 +1,203 @@ +#if os(Windows) + import Foundation + import Synchronization + import WinSDK + + /// Owns only this invocation's descendants. The handle is never inherited or exposed. + final class WindowsProcessJob: Sendable { + enum StopReason { case exited, timedOut, cancelled, failed } + + // Native handles identify kernel objects, not Swift memory. The enclosing + // mutex serializes every state mutation, borrowed admission and handle close. + private struct State: @unchecked Sendable { + let handle: HANDLE + var assigned = false + var reason: StopReason? + var failure: CommandRunnerError? + var members: [HANDLE] = [] + } + + private let state: Mutex + + init() throws { + guard let handle = CreateJobObjectW(nil, nil) else { + throw Self.error("CreateJobObjectW") + } + var limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + limits.BasicLimitInformation.LimitFlags = DWORD(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) + guard + SetInformationJobObject( + handle, JobObjectExtendedLimitInformation, &limits, + DWORD(MemoryLayout.size(ofValue: limits))) + else { + let error = Self.error("SetInformationJobObject") + CloseHandle(handle) + throw error + } + state = Mutex(State(handle: handle)) + } + + deinit { + state.withLock { + for member in $0.members { CloseHandle(member) } + CloseHandle($0.handle) + } + } + + var timedOut: Bool { state.withLock { $0.reason == .timedOut } } + + /// Called while the initial thread is suspended. The command owns the borrowed handles. + func start(process: HANDLE, thread: HANDLE) { + state.withLock { state in + if state.reason != nil { + Self.terminateSuspended(process, state: &state) + return + } + guard AssignProcessToJobObject(state.handle, process) else { + state.failure = Self.error("AssignProcessToJobObject") + state.reason = .failed + Self.terminateSuspended(process, state: &state) + return + } + state.assigned = true + guard ResumeThread(thread) != DWORD.max else { + state.failure = Self.error("ResumeThread") + Self.stop(.failed, state: &state) + return + } + } + } + + func stop(_ reason: StopReason) { + state.withLock { Self.stop(reason, state: &$0) } + } + + /// A successful termination request alone does not prove that descendants have exited. + func confirmCleanup() async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while true { + let complete = try state.withLock { state in + if let failure = state.failure { throw failure } + var accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION() + guard + QueryInformationJobObject( + state.handle, JobObjectBasicAccountingInformation, &accounting, + DWORD(MemoryLayout.size(ofValue: accounting)), nil) + else { throw Self.error("QueryInformationJobObject") } + var membersExited = true + for member in state.members { + switch WaitForSingleObject(member, 0) { + case DWORD(WAIT_OBJECT_0): break + case DWORD(WAIT_TIMEOUT): membersExited = false + default: throw Self.error("WaitForSingleObject") + } + } + return accounting.ActiveProcesses == 0 && membersExited + } + if complete { return } + guard ContinuousClock.now < deadline else { + throw CommandRunnerError.launchFailed( + "Command descendant cleanup could not be confirmed.") + } + try await Task.sleep(for: .milliseconds(10)) + } + } + + private static func stop(_ reason: StopReason, state: inout State) { + guard state.reason == nil else { return } + state.reason = reason + guard state.assigned else { return } + do { try retainMembers(state: &state) } catch { + state.failure = + (error as? CommandRunnerError) ?? .launchFailed("Cannot observe command descendants.") + } + if !TerminateJobObject(state.handle, 1) { + state.failure = state.failure ?? error("TerminateJobObject") + } + } + + /// Stop admission before enumerating, so a live member cannot create an unobserved child. + private static func retainMembers(state: inout State) throws { + var limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + limits.BasicLimitInformation.LimitFlags = DWORD( + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_ACTIVE_PROCESS) + limits.BasicLimitInformation.ActiveProcessLimit = 1 + guard + SetInformationJobObject( + state.handle, JobObjectExtendedLimitInformation, &limits, + DWORD(MemoryLayout.size(ofValue: limits))) + else { throw error("SetInformationJobObject") } + + var capacity = 16 + for _ in 0..<4 { + // The native structure ends in a variable-length ULONG_PTR process-id array. + let bytes = 2 * MemoryLayout.size + capacity * MemoryLayout.stride + let storage = UnsafeMutableRawPointer.allocate( + byteCount: bytes, alignment: MemoryLayout.alignment) + defer { storage.deallocate() } + storage.initializeMemory(as: UInt8.self, repeating: 0, count: bytes) + let queried = QueryInformationJobObject( + state.handle, JobObjectBasicProcessIdList, storage, DWORD(bytes), nil) + let code = GetLastError() + let assigned = Int(storage.load(as: DWORD.self)) + let count = Int(storage.load(fromByteOffset: MemoryLayout.size, as: DWORD.self)) + if !queried { + guard code == DWORD(ERROR_MORE_DATA), assigned > capacity, assigned <= 131_072 else { + throw WindowsCommandProcess.error("QueryInformationJobObject", code: code) + } + capacity = assigned + continue + } + guard count <= capacity, count == assigned else { + throw CommandRunnerError.launchFailed( + "Command descendant inventory changed during cleanup.") + } + for index in 0...size + index * MemoryLayout.stride, + as: ULONG_PTR.self) + guard let pid = DWORD(exactly: id) else { + throw CommandRunnerError.launchFailed("Invalid native command process identifier.") + } + guard + let member = OpenProcess( + DWORD(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION), false, pid) + else { + let code = GetLastError() + // A process whose object has already disappeared has completed termination. + if code == DWORD(ERROR_INVALID_PARAMETER) { continue } + throw WindowsCommandProcess.error("OpenProcess", code: code) + } + var belongs: WindowsBool = false + guard IsProcessInJob(member, state.handle, &belongs) else { + let failure = error("IsProcessInJob") + CloseHandle(member) + throw failure + } + if !belongs.boolValue { + let exited = WaitForSingleObject(member, 0) == DWORD(WAIT_OBJECT_0) + CloseHandle(member) + if exited { continue } + throw CommandRunnerError.launchFailed( + "Command process ownership changed during cleanup.") + } + state.members.append(member) + } + return + } + throw CommandRunnerError.launchFailed("Command descendant inventory could not be confirmed.") + } + + private static func terminateSuspended(_ process: HANDLE, state: inout State) { + if !TerminateProcess(process, 1), + WaitForSingleObject(process, 0) != DWORD(WAIT_OBJECT_0) + { + state.failure = error("TerminateProcess") + } + } + + private static func error(_ operation: String) -> CommandRunnerError { + .launchFailed("\(operation) failed (Windows error \(GetLastError())).") + } + } +#endif diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 262004c..e1009c0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,7 +4,7 @@ The adapter retains the Computer MCP source-visible license for extracted and original integration code. Moving that code into this repository does not change its ownership or grant additional distribution rights. -The official MCP Swift SDK and swift-codex are MIT-licensed. Swift Subprocess +The MCP Swift SDK transport fork and swift-codex are MIT-licensed. Swift Subprocess and other Swift dependencies retain their own upstream licenses. The packaging script copies license and notice files from each pinned SwiftPM checkout into `ThirdPartyNotices/`, together with the unchanged `Package.resolved`. This is a @@ -16,6 +16,19 @@ The upstream Apache 2.0 license and applicable schema notice are included in `ThirdPartyNotices/codex-protocol/`. No vendor Codex executable, credentials, or user configuration is bundled. +Windows archives include the required Swift runtime DLLs alongside the adapter. +`ThirdPartyNotices/windows-runtime/` preserves the original Swift, Foundation, +libdispatch, ICU, string-processing, curl and zlib notices. Its `sources.json` +binds those texts to upstream commits and maps runtime libraries to notices. +`ThirdPartyNotices/sqlite/` retains the original SQLite header with its +public-domain statement and the pinned source identity. The package receipt +separately binds the selected runtime DLLs and compiled SQLite library. + +Microsoft Visual C++ runtime files retain their separate Microsoft redistribution +terms. The applicable publisher license must permit redistribution; their +presence in the Swift toolchain does not establish that permission. See the +[Microsoft redistribution guidance](https://learn.microsoft.com/en-us/cpp/windows/redistributing-visual-cpp-files?view=msvc-170). + An ad-hoc signature and checksum support local integrity checks. They do not establish official publisher provenance, Developer ID signing, notarization, or permission to publish this package. diff --git a/Tests/CodexAdapterTests/CodexAppAsyncWorkTests.swift b/Tests/CodexAdapterTests/CodexAppAsyncWorkTests.swift new file mode 100644 index 0000000..b9ab59e --- /dev/null +++ b/Tests/CodexAdapterTests/CodexAppAsyncWorkTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing + +@testable import CodexAdapter + +struct CodexAppAsyncWorkTests { + @Test + func nativeLoginAcknowledgementEnrichesTheSameOwnedLifetime() throws { + var work = CodexAppAsyncWork() + let creator = UUID() + let ticket = try CodexWorkInvocation.$current.withValue(creator) { + try work.prepare( + method: "account/login/start", params: .object(["type": .string("chatgpt")]), + generation: 1) + } + let pending = try #require(work.workResources().first) + #expect(pending.handles.isEmpty) + work.replied(ticket, response: .object(["loginId": .string("native-login")])) + let acknowledged = try #require(work.workResources().first) + #expect(acknowledged.id == pending.id && acknowledged.acquiredBy == creator) + #expect(acknowledged.handles == ["login_id": .string("native-login")]) + work.failed(ticket, rejected: false) + let uncertain = try #require(work.workResources().first) + #expect(uncertain.handles == acknowledged.handles && uncertain.state == .uncertain) + work.notified( + method: "account/login/completed", params: ["loginId": .string("other")], generation: 1) + #expect(try work.workResources() == [uncertain]) + work.notified( + method: "account/login/completed", params: ["loginId": .string("native-login")], + generation: 1) + #expect(try work.workResources().isEmpty) + } + + @Test + func excessUnboundNativeSessionsCannotTurnIntoAnEmptySnapshot() throws { + var work = CodexAppAsyncWork() + for index in 0...256 { + work.notified( + method: "thread/realtime/started", params: ["threadId": .string("thread-\(index)")], + generation: 1) + } + for index in 0...256 { + work.notified( + method: "thread/realtime/closed", params: ["threadId": .string("thread-\(index)")], + generation: 1) + } + #expect(!work.isEmpty) + #expect(throws: (any Error).self) { try work.workResources() } + work.retired(generation: 2) + #expect(throws: (any Error).self) { try work.workResources() } + work.retired(generation: 1) + #expect(work.isEmpty) + #expect(try work.workResources().isEmpty) + } +} diff --git a/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift b/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift index d2cbdc2..458a05a 100644 --- a/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift +++ b/Tests/CodexAdapterTests/CodexAppServerProcessTransportTests.swift @@ -6,6 +6,42 @@ import Testing @Suite(.serialized) struct CodexAppServerProcessTransportTests { + @Test(.timeLimit(.minutes(1))) + func supervisorExitDoesNotProveChildGroupCleanup() async throws { + let transport = try ManagedLineProcess( + configuration: .init( + executable: "/usr/bin/python3", + arguments: [ + "-c", + "import os,time; print(os.getpid(),flush=True); os.close(1); os.close(2); time.sleep(3)", + ], + workingDirectory: FileManager.default.temporaryDirectory, + terminationGraceMilliseconds: 10, killGraceMilliseconds: 100)) + var lines = transport.inboundLines.makeAsyncIterator() + let ready = try #require(try await lines.next()) + let child = try #require(Int32(ready)) + let running = await transport.snapshot() + let supervisor = try #require(running.supervisorProcessID) + #expect(Darwin.kill(supervisor, SIGKILL) == 0) + let deadline = ContinuousClock.now + .seconds(2) + while !(await transport.snapshot().hasExited), ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + let orphaned = await transport.snapshot() + #expect(orphaned.hasExited) + #expect(orphaned.cleanupConfirmed == false) + #expect(orphaned.state == .failed) + await transport.close() + #expect(Darwin.kill(child, 0) == 0) + #expect(await transport.snapshot().cleanupConfirmed == false) + let cleanupDeadline = ContinuousClock.now + .seconds(4) + while await transport.snapshot().cleanupConfirmed != true, ContinuousClock.now < cleanupDeadline + { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(await transport.snapshot().cleanupConfirmed == true) + } + @Test func oversizedTrailingFrameFailsAfterAValidLine() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Tests/CodexAdapterTests/CodexAppServerProviderTests.swift b/Tests/CodexAdapterTests/CodexAppServerProviderTests.swift index ad4c6de..657f391 100644 --- a/Tests/CodexAdapterTests/CodexAppServerProviderTests.swift +++ b/Tests/CodexAdapterTests/CodexAppServerProviderTests.swift @@ -6,6 +6,117 @@ import Testing @Suite(.serialized) struct CodexAppServerProviderTests { + @Test + func hostServiceConsumersDeclareTheirExactSemanticEffect() { + let expected = [ + "codex.diagnostics.snapshot": "diagnostics.snapshot", + "codex.worktree.provision.perform": "workspaces.provision", + "codex.worktree.remove.perform": "workspaces.remove", + ] + for tool in makeProvider().tools { + #expect(tool._meta?["io.github.computer-mcp/host-action"]?.stringValue == expected[tool.name]) + } + } + + @Test + func modelAndApprovalEntryPointsDeclareFullShellAcrossTheMCPCatalog() throws { + let tools = makeProvider().tools + for tool in tools { + let raw = try #require( + tool._meta?["io.github.computer-mcp/risk"]?.stringValue) + #expect(CodexOperationRisk(rawValue: raw) != nil) + #expect(tool.annotations.readOnlyHint == (raw == "read-only")) + } + for name in [ + "codex.app.methods.call", "codex.app.thread.start", "codex.app.thread.reclaim", + "codex.app.thread.fork", "codex.app.thread.release", "codex.app.turn.start", + "codex.app.turn.steer", "codex.app.turn.interrupt", "codex.app.review.start", + "codex.app.requests.respond", "codex.app.approvals.respond", "codex.app.goal.set", + ] { + let tool = try #require(tools.first { $0.name == name }) + #expect(tool._meta?["io.github.computer-mcp/risk"] == .string("full-shell")) + } + for name in ["codex.app.thread.read", "codex.app.approvals.read", "codex.app.events.read"] { + let tool = try #require(tools.first { $0.name == name }) + #expect(tool._meta?["io.github.computer-mcp/risk"] == .string("read-only")) + } + let goal = try #require(tools.first { $0.name == "codex.app.goal.clear" }) + #expect( + goal._meta?["io.github.computer-mcp/risk"] == .string("workspace-write")) + } + + @Test + func threadHistoryDefaultsToMetadataAndBoundedNativePages() async throws { + let provider = makeProvider() + for includeTurns in [nil, false, true] as [Bool?] { + var arguments: [String: JSONValue] = ["thread_id": .string("thread-1")] + if let includeTurns { arguments["include_turns"] = .bool(includeTurns) } + let result = try await provider.call( + name: "codex.app.thread.read", arguments: .object(arguments)) + #expect( + result.structuredContent?.objectValue?["result"]?.objectValue?["params"]? + .objectValue?["includeTurns"] == .bool(includeTurns ?? false)) + } + for (tool, method) in [ + ("codex.app.thread.reclaim", "thread/resume"), ("codex.app.thread.fork", "thread/fork"), + ] { + for includeTurns in [nil, false, true] as [Bool?] { + var arguments: [String: JSONValue] = ["thread_id": .string("thread-1")] + if let includeTurns { arguments["include_turns"] = .bool(includeTurns) } + let result = try await provider.call(name: tool, arguments: .object(arguments)) + let params = try #require( + result.structuredContent?.objectValue?["result"]?.objectValue?["params"]) + #expect(params.objectValue?["excludeTurns"] == .bool(!(includeTurns ?? false))) + try #require(CodexAppServerMethodCatalog.method(named: method)).validate( + params: JSONValue.encoded(params)) + } + } + for (tool, method, limit) in [ + ("codex.app.thread.turns.list", "thread/turns/list", 20), + ("codex.app.thread.items.list", "thread/items/list", 50), + ] { + let result = try await provider.call( + name: tool, arguments: .object(["thread_id": .string("thread-1")])) + let body = try #require(result.structuredContent?.objectValue?["result"]?.objectValue) + let params = try #require(body["params"]?.objectValue) + #expect(body["method"] == .string(method)) + #expect(params["limit"] == .int(limit)) + #expect(params["sortDirection"] == .string("desc")) + #expect(params["itemsView"] == (method == "thread/turns/list" ? .string("notLoaded") : nil)) + for invalid in [JSONValue.integer(0), .integer(101), .number(1.5), .string("20")] { + await #expect(throws: CodexToolError.self) { + try await provider.call( + name: tool, + arguments: .object([ + "thread_id": .string("thread-1"), "limit": invalid, + ])) + } + } + } + let cursor = "opaque/+cursor==:9007199254740993" + let result = try await provider.call( + name: "codex.app.thread.items.list", + arguments: .object([ + "thread_id": .string("thread-1"), "turn_id": .string("turn-7"), + "cursor": .string(cursor), "sort_direction": .string("asc"), "limit": .integer(1), + ])) + #expect( + result.structuredContent?.objectValue?["result"]?.objectValue?["params"] + == .object([ + "threadId": .string("thread-1"), "turnId": .string("turn-7"), + "cursor": .string(cursor), "sortDirection": .string("asc"), "limit": .int(1), + ])) + for field in ["sort_direction", "items_view"] { + await #expect(throws: CodexToolError.self) { + try await provider.call( + name: "codex.app.thread.turns.list", + arguments: .object([ + "thread_id": .string("thread-1"), field: .string("unsupported"), + ])) + } + } + } + @Test(.timeLimit(.minutes(2))) func mcpWorkflowUsesOriginalRuntimeAndPersistsRelease() async throws { let fixture = try AppServerProcessFixture() @@ -64,6 +175,28 @@ struct CodexAppServerProviderTests { #expect(goal.objectValue?["goal"]?.objectValue?["tokenBudget"] == .int(50_000)) _ = try await invoke("codex.app.goal.get", ["thread_id": .string("thread_fixture")]) _ = try await invoke("codex.app.goal.clear", ["thread_id": .string("thread_fixture")]) + try Data("9007199254740993".utf8).write( + to: fixture.directory.appendingPathComponent("goal-token-budget")) + let nativeGoal = try await invoke( + "codex.app.native.thread.goal.set", + [ + "params": .object([ + "threadId": .string("thread_fixture"), + "objective": .string("Verify exact integer transport."), + "tokenBudget": .int(9_007_199_254_740_993), + ]) + ]) + #expect( + nativeGoal.objectValue?["goal"]?.objectValue?["tokenBudget"] == .int(9_007_199_254_740_993)) + let nativeRequests = try fixture.requests().map { + try JSONDecoder().decode(JSONValue.self, from: Data($0.utf8)) + } + #expect( + nativeRequests.contains { + $0.objectValue?["method"] == .string("thread/goal/set") + && $0.objectValue?["params"]?.objectValue?["tokenBudget"] + == .integer(9_007_199_254_740_993) + }) var approvalID: String? for _ in 0..<500 { let approvals = try await invoke("codex.app.approvals.list", ["state": .string("pending")]) @@ -149,6 +282,30 @@ struct CodexAppServerProviderTests { #expect((result?["params"]?.objectValue?["model"]) == (.string("gpt-test"))) } + @Test + func experimentalCallsRequireExplicitAdmissionAndRemainDistinctFromStableTools() async throws { + let provider = makeProvider() + #expect(provider.tools.filter { $0.name.hasPrefix("codex.app.native.") }.count == 96) + #expect(!provider.tools.contains { $0.name == "codex.app.native.mock.experimentalMethod" }) + await #expect(throws: CodexToolError.self) { + try await provider.call( + name: "codex.app.methods.call", + arguments: .object([ + "method": .string("mock/experimentalMethod"), + "params": .object(["value": .string("probe")]), + ])) + } + let result = try await provider.call( + name: "codex.app.methods.call", + arguments: .object([ + "method": .string("mock/experimentalMethod"), "experimental": .bool(true), + "params": .object(["value": .string("probe")]), + ])) + #expect( + result.structuredContent?.objectValue?["result"]?.objectValue?["method"] + == .string("mock/experimentalMethod")) + } + @Test func testTypedAppToolsMapStableArgumentsWithoutRawParams() async throws { let provider = makeProvider() diff --git a/Tests/CodexAdapterTests/CodexAppServerRuntimeTests.swift b/Tests/CodexAdapterTests/CodexAppServerRuntimeTests.swift index eb4233b..ca88c0f 100644 --- a/Tests/CodexAdapterTests/CodexAppServerRuntimeTests.swift +++ b/Tests/CodexAdapterTests/CodexAppServerRuntimeTests.swift @@ -8,6 +8,60 @@ import Testing @Suite(.serialized) final class CodexAppServerRuntimeTests { + @Test + func historyPagesPreserveCursorsAndExtensionsOrFailWithoutTruncating() async throws { + let fixture = try AppServerProcessFixture() + defer { fixture.remove() } + let runtime = fixture.makeRuntime() + let responseFile = fixture.directory.appendingPathComponent("history-page.json") + let page: JSONValue = .object([ + "data": .array([ + .object(["id": .string("item-1"), "futureItem": .integer(9_007_199_254_740_993)]) + ]), + "nextCursor": .string("native-opaque/+=="), "futurePage": .bool(true), + ]) + do { + _ = try await runtime.call(method: "thread/loaded/list", params: .object([:])) + try JSONEncoder().encode(page).write(to: responseFile) + let arguments: JSONValue = .object([ + "threadId": .string("thread_fixture"), "cursor": .string("input-opaque/+=="), + "limit": .integer(1), + ]) + for method in ["thread/turns/list", "thread/items/list"] { + #expect(try await runtime.call(method: method, params: arguments) == page) + } + let oversized: JSONValue = .object([ + "data": .array([ + .object([ + "id": .string("item-1"), "text": .string(String(repeating: "x", count: 1_048_576)), + ]) + ]), + "nextCursor": .string("must-not-advance"), + ]) + try JSONEncoder().encode(oversized).write(to: responseFile) + do { + _ = try await runtime.call(method: "thread/items/list", params: arguments) + Issue.record("An oversized page must not succeed as a truncated cursorless preview.") + } catch { + #expect(error.localizedDescription.contains("codex.app.history_page_too_large")) + } + try JSONEncoder().encode(page).write(to: responseFile) + #expect(try await runtime.call(method: "thread/items/list", params: arguments) == page) + let requests = try fixture.requests().map { + try JSONDecoder().decode(JSONValue.self, from: Data($0.utf8)) + } + #expect( + requests.filter { $0.objectValue?["method"] == .string("thread/items/list") }.allSatisfy { + $0.objectValue?["params"] == arguments + }) + #expect(try fixture.processIDs().count == 1) + await runtime.shutdown() + } catch { + await runtime.shutdown() + throw error + } + } + @Test(arguments: [false, true]) func testUnavailableExecutableReportsFailedStartup(missingInterpreter: Bool) async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -29,6 +83,52 @@ final class CodexAppServerRuntimeTests { #expect(status.objectValue?["last_error"]?.stringValue?.isEmpty == false) } + @Test(arguments: [false, true]) + func confirmedRetirementReleasesLoadedAndActiveThreadOwnership(timeout: Bool) async throws { + let fixture = try AppServerProcessFixture() + defer { fixture.remove() } + let runtime = fixture.makeRuntime(requestTimeoutSeconds: 1) + do { + _ = try await runtime.call(method: "thread/start", params: .object([:])) + _ = try await runtime.call( + method: "turn/start", + params: .object([ + "threadId": .string("thread_native"), + "input": .array([.object(["type": .string("text"), "text": .string("fixture")])]), + ])) + #expect(await runtime.hasLiveOwnership(of: "thread_native")) + let pid = try await fixture.waitForLatestPID(count: 1) + if timeout { + try Data().write(to: fixture.hangRequestsFile) + await assertThrowsErrorAsync( + try await runtime.call(method: "thread/loaded/list", params: .object([:]))) + } else { + #expect(Darwin.kill(pid, SIGTERM) == 0) + try await waitUntilRuntimeCondition { + let status = await runtime.status() + return status.objectValue?["connection_state"] != .string("running") + && status.objectValue?["process"]?.objectValue?["cleanup_confirmed"] == .bool(true) + } + } + let status = await runtime.status() + #expect(status.objectValue?["process"]?.objectValue?["cleanup_confirmed"] == .bool(true)) + #expect(await waitForProcessExit(pid)) + #expect(!(await runtime.hasLiveOwnership(of: "thread_native"))) + #expect( + await CodexRuntimeDirectory.shared.runtimeIDs( + owning: "thread_native", workspaceID: "fixture-workspace" + ).isEmpty) + if timeout { try FileManager.default.removeItem(at: fixture.hangRequestsFile) } + _ = try await runtime.call(method: "thread/loaded/list", params: .object([:])) + #expect(await runtime.hasLiveOwnership(of: "thread_fixture")) + #expect(!(await runtime.hasLiveOwnership(of: "thread_native"))) + await runtime.shutdown() + } catch { + await runtime.shutdown() + throw error + } + } + @Test func testReleaseForHandoffVerifiesLoadedStateAndReapsEmptyRuntime() async throws { let fixture = try AppServerProcessFixture() @@ -482,11 +582,15 @@ final class CodexAppServerRuntimeTests { await runtime.shutdown() } - @Test - func testConnectionStartupIsBoundedByEndToEndDeadline() async throws { + @Test(arguments: [false, true]) + func testConnectionStartupIsBoundedByEndToEndDeadline(ignoreTermination: Bool) async throws { let fixture = try AppServerProcessFixture() defer { fixture.remove() } try Data().write(to: fixture.hangInitializeFile) + if ignoreTermination { + try Data().write( + to: fixture.directory.appendingPathComponent("ignore-initialize-termination")) + } let runtime = fixture.makeRuntime(requestTimeoutSeconds: 1) let clock = ContinuousClock() let started = clock.now @@ -499,7 +603,22 @@ final class CodexAppServerRuntimeTests { #expect(elapsed < .seconds(3)) let processID = try await fixture.waitForLatestPID(count: 1) #expect(await waitForProcessExit(processID)) - #expect(!FileManager.default.fileExists(atPath: fixture.leaseDirectory.path)) + let status = await runtime.status() + let process = try #require(status.objectValue?["process"]?.objectValue) + #expect(process["cleanup_confirmed"] == .bool(true)) + let supervisorID = Int32(try #require(process["supervisor_process_id"]?.intValue)) + let groupID = Int32(try #require(process["process_group_id"]?.intValue)) + #expect(!processExists(supervisorID)) + let groupProbe = Darwin.kill(-groupID, 0) + let groupError = errno + #expect(groupProbe == -1 && groupError == ESRCH) + #expect(try await runtime.workResources().isEmpty) + #expect(status.objectValue?["current_request_count"] == .integer(0)) + if ignoreTermination { + #expect(process["termination_escalated"] == .bool(true)) + // SIGKILL cannot run the child's EXIT trap; filesystem residue is not live ownership. + #expect(FileManager.default.fileExists(atPath: fixture.leaseDirectory.path)) + } await runtime.shutdown() } @@ -1434,6 +1553,9 @@ struct AppServerProcessFixture { IFS= read -r line || exit 74 if [ -f "$fixture_dir/hang-initialize" ]; then + if [ -f "$fixture_dir/ignore-initialize-termination" ]; then + trap '' HUP INT TERM + fi /bin/sleep 60 exit 76 fi @@ -1441,6 +1563,9 @@ struct AppServerProcessFixture { printf '{"id":%s,"result":{"codexHome":"%s","platformFamily":"unix","platformOs":"macos","userAgent":"Codex/computer-mcp-fixture"}}\n' "$id" "$fixture_dir" IFS= read -r line || exit 75 printf '%s\n' "$line" >> "$fixture_dir/requests.log" + remote_status=disabled + if [ -f "$fixture_dir/initial-remote-status" ]; then remote_status=$(/bin/cat "$fixture_dir/initial-remote-status"); fi + printf '{"method":"remoteControl/status/changed","params":{"status":"%s","installationId":"fixture-installation","serverName":"fixture"}}\n' "$remote_status" if [ -f "$fixture_dir/approval-request.json" ]; then /bin/cat "$fixture_dir/approval-request.json" printf '\n' @@ -1458,13 +1583,33 @@ struct AppServerProcessFixture { while IFS= read -r line; do printf '%s\n' "$line" >> "$fixture_dir/requests.log" case "$line" in - *'"id":900'*|*'"id":"900"'*) + *'"method":'*) ;; + *) printf '%s\n' "$line" >> "$fixture_dir/approval-response.log" continue ;; esac id=$(printf '%s\n' "$line" | /usr/bin/sed -E 's/.*"id":("[^"]*"|[0-9]+).*/\\1/') + if [ -f "$fixture_dir/notifications-next.jsonl" ]; then + /bin/cat "$fixture_dir/notifications-next.jsonl" + /bin/rm "$fixture_dir/notifications-next.jsonl" + while [ -f "$fixture_dir/hold-notification-response" ]; do /bin/sleep 0.01; done + fi case "$line" in + *remoteControl*enable*|*remoteControl*disable*) + printf '{"id":%s,"result":{"status":"disabled","installationId":"fixture-installation","serverName":"fixture"}}\n' "$id" + ;; + *account*login*start*) + login_id=11111111-1111-1111-1111-111111111111 + if [ -f "$fixture_dir/login-id" ]; then login_id=$(/bin/cat "$fixture_dir/login-id"); fi + printf '{"id":%s,"result":{"type":"chatgpt","loginId":"%s","authUrl":"https://example.invalid/login"}}\\n' "$id" "$login_id" + ;; + *account*login*cancel*) + printf '{"id":%s,"result":{"status":"canceled"}}\\n' "$id" + ;; + *mcpServer*oauth*login*) + printf '{"id":%s,"result":{"authorizationUrl":"https://example.invalid/oauth"}}\\n' "$id" + ;; *thread*loaded*list*) if [ -f "$fixture_dir/hang-requests" ]; then : > "$fixture_dir/hang-request-received" @@ -1484,14 +1629,41 @@ struct AppServerProcessFixture { fi printf '{"id":%s,"result":{"data":%s,"nextCursor":null}}\n' "$id" "$loaded" ;; + *thread*turns*list*|*thread*items*list*) + printf '{"id":%s,"result":' "$id" + /bin/cat "$fixture_dir/history-page.json" + printf '}\n' + ;; *thread*unsubscribe*) + if [ -f "$fixture_dir/closed-threads-after-unsubscribe.jsonl" ]; then + /bin/cat "$fixture_dir/closed-threads-after-unsubscribe.jsonl" + /bin/rm "$fixture_dir/closed-threads-after-unsubscribe.jsonl" + fi if [ -f "$fixture_dir/loaded-threads-after-unsubscribe.json" ]; then /bin/cp "$fixture_dir/loaded-threads-after-unsubscribe.json" "$fixture_dir/loaded-threads.json" fi printf '{"id":%s,"result":{"status":"unsubscribed"}}\n' "$id" ;; - *thread*start*) - printf '{"id":%s,"result":{"futureResponse":{"preserved":true},"approvalPolicy":"on-request","approvalsReviewer":"user","cwd":"%s","model":"gpt-test","modelProvider":"openai","sandbox":{"type":"dangerFullAccess"},"thread":{"cliVersion":"fixture","createdAt":1,"cwd":"%s","ephemeral":false,"id":"thread_native","modelProvider":"openai","preview":"","sessionId":"session_native","source":"appServer","status":{"type":"idle"},"turns":[],"updatedAt":1}}}\n' "$id" "$workspace_dir" "$workspace_dir" + *thread*queue*add*) + printf '{"id":%s,"result":{"queuedSubmission":{"id":"queued-native","clientUserMessageId":"queued-client","input":[]}}}\n' "$id" + ;; + *thread*queue*delete*) + printf '{"id":%s,"result":{"deleted":true}}\n' "$id" + ;; + *thread*archive*|*thread*realtime*start*|*thread*realtime*stop*) + printf '{"id":%s,"result":{}}\n' "$id" + ;; + *review*start*|*thread*queue*start*) + turn_id=turn_native + if [ -f "$fixture_dir/created-turn-id" ]; then turn_id=$(/bin/cat "$fixture_dir/created-turn-id"); fi + review_thread_id=thread_native + if [ -f "$fixture_dir/review-thread-id" ]; then review_thread_id=$(/bin/cat "$fixture_dir/review-thread-id"); fi + printf '{"id":%s,"result":{"reviewThreadId":"%s","turn":{"id":"%s","items":[],"status":"inProgress"}}}\\n' "$id" "$review_thread_id" "$turn_id" + ;; + *thread*start*|*thread*resume*) + thread_id=thread_native + if [ -f "$fixture_dir/created-thread-id" ]; then thread_id=$(/bin/cat "$fixture_dir/created-thread-id"); fi + printf '{"id":%s,"result":{"futureResponse":{"preserved":true},"approvalPolicy":"on-request","approvalsReviewer":"user","cwd":"%s","model":"gpt-test","modelProvider":"openai","sandbox":{"type":"dangerFullAccess"},"thread":{"cliVersion":"fixture","createdAt":1,"cwd":"%s","ephemeral":false,"id":"%s","modelProvider":"openai","preview":"","sessionId":"session_native","source":"appServer","status":{"type":"idle"},"turns":[],"updatedAt":1}}}\n' "$id" "$workspace_dir" "$workspace_dir" "$thread_id" ;; *turn*interrupt*) printf '{"id":%s,"result":{}}\n' "$id" @@ -1500,13 +1672,19 @@ struct AppServerProcessFixture { if [ -f "$fixture_dir/hang-turn-start" ]; then continue fi - printf '{"id":%s,"result":{"turn":{"id":"turn_native","items":[],"status":"inProgress"}}}\n' "$id" + turn_id=turn_native + if [ -f "$fixture_dir/created-turn-id" ]; then turn_id=$(/bin/cat "$fixture_dir/created-turn-id"); fi + printf '{"id":%s,"result":{"turn":{"id":"%s","items":[],"status":"inProgress"}}}\n' "$id" "$turn_id" ;; *thread*goal*set*) - printf '{"id":%s,"result":{"goal":{"createdAt":1,"objective":"Pass every acceptance criterion.","status":"active","threadId":"thread_fixture","timeUsedSeconds":30,"tokenBudget":50000,"tokensUsed":1250,"updatedAt":2}}}\n' "$id" + budget=50000 + if [ -f "$fixture_dir/goal-token-budget" ]; then budget=$(/bin/cat "$fixture_dir/goal-token-budget"); fi + printf '{"id":%s,"result":{"goal":{"createdAt":1,"objective":"Pass every acceptance criterion.","status":"active","threadId":"thread_fixture","timeUsedSeconds":30,"tokenBudget":%s,"tokensUsed":1250,"updatedAt":2}}}\n' "$id" "$budget" ;; *thread*goal*get*) - printf '{"id":%s,"result":{"goal":{"createdAt":1,"objective":"Pass every acceptance criterion.","status":"active","threadId":"thread_fixture","timeUsedSeconds":30,"tokenBudget":50000,"tokensUsed":1250,"updatedAt":2}}}\n' "$id" + goal_status=active + if [ -f "$fixture_dir/goal-read-status" ]; then goal_status=$(/bin/cat "$fixture_dir/goal-read-status"); fi + printf '{"id":%s,"result":{"goal":{"createdAt":1,"objective":"Pass every acceptance criterion.","status":"%s","threadId":"thread_fixture","timeUsedSeconds":30,"tokenBudget":50000,"tokensUsed":1250,"updatedAt":2}}}\n' "$id" "$goal_status" ;; *thread*goal*clear*) printf '{"id":%s,"result":{"cleared":true}}\n' "$id" @@ -1531,7 +1709,8 @@ struct AppServerProcessFixture { requestTimeoutSeconds: Int = CodexConfig().appServerRequestTimeoutSeconds, approvalTimeoutSeconds: Int = 300, database: CodexDatabase? = nil, - workspaceID: String? = "fixture-workspace" + workspaceID: String? = "fixture-workspace", + dynamicToolDispatcher: (any CodexHostTools)? = nil ) -> LiveCodexAppServerRuntime { LiveCodexAppServerRuntime( configuration: CodexConfig( @@ -1554,7 +1733,7 @@ struct AppServerProcessFixture { tunnelInstanceID: nil, tunnelProfileID: nil ), - database: database + database: database, dynamicToolDispatcher: dynamicToolDispatcher ) } @@ -1653,6 +1832,19 @@ struct AppServerProcessFixture { let encoder = CanonicalJSONCoding.encoder(outputFormatting: [.sortedKeys]) try encoder.encode(initial).write(to: loadedThreadsFile) try encoder.encode(afterUnsubscribe).write(to: loadedThreadsAfterUnsubscribeFile) + let closed = Set(initial).subtracting(afterUnsubscribe).sorted() + var notifications = Data() + for threadID in closed { + notifications.append( + try encoder.encode( + JSONValue.object([ + "method": .string("thread/closed"), + "params": .object(["threadId": .string(threadID)]), + ]))) + notifications.append(10) + } + try notifications.write( + to: directory.appendingPathComponent("closed-threads-after-unsubscribe.jsonl")) } func requests() throws -> [String] { @@ -1756,7 +1948,7 @@ private func replaceWorkspacePlaceholder(_ value: JSONValue, with workspace: Str } ) ) - case .number, .bool, .null: + case .number, .integer, .bool, .null: return value } } diff --git a/Tests/CodexAdapterTests/CodexAppWorkOwnershipTests.swift b/Tests/CodexAdapterTests/CodexAppWorkOwnershipTests.swift new file mode 100644 index 0000000..3fc4c90 --- /dev/null +++ b/Tests/CodexAdapterTests/CodexAppWorkOwnershipTests.swift @@ -0,0 +1,1068 @@ +import Darwin +import Foundation +import Testing + +@testable import CodexAdapter + +@Suite(.serialized) +struct CodexAppWorkOwnershipTests { + @Test + func threadAndTurnKeepDifferentCreatorsAndReleasedThreadGetsANewLifetime() async throws { + try await withRuntime { fixture, runtime in + let threadCreator = UUID() + let turnCreator = UUID() + _ = try await invoke(runtime, "thread/start", creator: threadCreator) + let first = try #require(try await runtime.workResources().first) + #expect(first.kind == "codex.app.thread") + #expect(first.acquiredBy == threadCreator) + #expect( + first.handles == [ + "thread_id": .string("thread_native"), "runtime_id": .string(runtime.runtimeID), + ]) + _ = try await invoke(runtime, "turn/start", params: turnParams(), creator: turnCreator) + let rows = try await runtime.workResources() + #expect(rows.count == 2) + #expect(rows.first { $0.kind == "codex.app.turn" }?.acquiredBy == turnCreator) + #expect( + rows.first { $0.kind == "codex.app.turn" }?.handles == [ + "thread_id": .string("thread_native"), "turn_id": .string("turn_native"), + "runtime_id": .string(runtime.runtimeID), + ]) + #expect(rows.first { $0.kind == "codex.app.thread" } == first) + _ = try await invoke(runtime, "thread/unsubscribe", params: threadParams()) + #expect(try await runtime.workResources() == rows) + try inject( + fixture, + [ + .object([ + "method": .string("thread/closed"), "params": threadParams(), + ]) + ]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + try await until { try await runtime.workResources().isEmpty } + let replacementCreator = UUID() + _ = try await invoke(runtime, "thread/start", creator: replacementCreator) + let replacement = try #require(try await runtime.workResources().first) + #expect(replacement.id != first.id) + #expect(replacement.acquiredBy == replacementCreator) + #expect(replacement.handles == first.handles) + } + } + + @Test + func threadClosedBeforeCreationReplyDoesNotRegainLiveOwnership() async throws { + try await withRuntime { fixture, runtime in + let gate = fixture.directory.appendingPathComponent("hold-notification-response") + try Data().write(to: gate) + try inject( + fixture, + [ + .object([ + "method": .string("thread/closed"), "params": threadParams(), + ]) + ]) + let starting = Task { try await invoke(runtime, "thread/start") } + defer { starting.cancel() } + try await until { + await runtime.events(afterCursor: 0, maxResults: 100).objectValue?["events"]?.arrayValue? + .contains { + $0.objectValue?["payload"]?.objectValue?["method"] == .string("thread/closed") + } == true + } + try FileManager.default.removeItem(at: gate) + _ = try await starting.value + #expect(try await runtime.workResources().isEmpty) + #expect(!(await runtime.hasLiveOwnership(of: "thread_native"))) + } + } + + @Test + func missingLoadedThreadWaitsForClosureAndCannotBeResumedOverCleanup() async throws { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + let original = try #require(try await runtime.workResources().first) + _ = try await invoke(runtime, "thread/unsubscribe", params: threadParams()) + try Data("[]".utf8).write(to: fixture.loadedThreadsFile) + _ = try await invoke(runtime, "thread/loaded/list") + let uncertain = try #require(try await runtime.workResources().first) + #expect(uncertain.id == original.id) + #expect(uncertain.acquiredBy == original.acquiredBy) + #expect(uncertain.state == .uncertain) + await #expect(throws: (any Error).self) { + try await invoke(runtime, "thread/resume", params: threadParams()) + } + try inject( + fixture, + [ + .object([ + "method": .string("thread/closed"), "params": threadParams(), + ]) + ]) + _ = try await invoke(runtime, "thread/loaded/list") + try await until { try await runtime.workResources().isEmpty } + } + } + + @Test + func archiveCloseNotificationKeepsUnconfirmedNativeCleanupOwned() async throws { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + let original = try #require(try await runtime.workResources().first) + try inject( + fixture, + [ + .object([ + "method": .string("thread/closed"), "params": threadParams(), + ]) + ]) + _ = try await invoke(runtime, "thread/archive", params: threadParams()) + try await until { + await runtime.status().objectValue?["threads"]?.arrayValue?.first?.objectValue?["state"] + == .string("closed") + } + let retained = try #require(try await runtime.workResources().first) + #expect(retained.id == original.id) + #expect(retained.state == .uncertain) + await runtime.shutdown() + #expect(try await runtime.workResources().isEmpty) + } + } + + @Test + func handoffCannotReapAnUnrelatedPendingLogin() async throws { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + try Data("[\"thread_native\"]".utf8).write(to: fixture.loadedThreadsFile) + let creator = UUID() + _ = try await invoke( + runtime, "account/login/start", params: .object(["type": .string("chatgpt")]), + creator: creator) + let preparation = try await runtime.prepareForHandoff( + threadID: "thread_native", mode: .graceful, interruptActiveTurn: false) + await #expect(throws: (any Error).self) { + try await runtime.releaseForHandoff( + threadID: "thread_native", mode: .graceful, interruptActiveTurn: false, + preparationID: preparation) + } + #expect( + try await runtime.workResources().first { $0.kind == "codex.app.login" }?.acquiredBy + == creator) + #expect(await runtime.status().objectValue?["connection_state"] != .string("stopped")) + } + } + + @Test + func remoteControlDisableRetainsItsOwnerUntilNativeProcessCleanup() async throws { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + let creator = UUID() + _ = try await invoke(runtime, "remoteControl/enable", creator: creator) + let first = try #require( + try await runtime.workResources().first { $0.kind == "codex.app.remote-control" }) + try inject(fixture, [remoteStatus("disabled")]) + _ = try await invoke(runtime, "remoteControl/disable") + let retained = try #require( + try await runtime.workResources().first { $0.kind == "codex.app.remote-control" }) + #expect(retained.id == first.id) + #expect(retained.acquiredBy == creator) + #expect(retained.state == .uncertain) + await runtime.shutdown() + #expect(try await runtime.workResources().isEmpty) + } + } + + @Test + func persistedRemoteControlBelongsToTheConnectionCreator() async throws { + try await withRuntime { fixture, runtime in + try Data("connected".utf8).write( + to: fixture.directory.appendingPathComponent("initial-remote-status")) + let creator = UUID() + _ = try await invoke(runtime, "thread/start", creator: creator) + let remote = try #require( + try await runtime.workResources().first { $0.kind == "codex.app.remote-control" }) + #expect(remote.acquiredBy == creator) + try inject(fixture, [remoteStatus("disabled")]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + try await until { + try await runtime.workResources().first { $0.kind == "codex.app.remote-control" }?.state + == .uncertain + } + #expect(try await runtime.workResources().contains { $0.id == remote.id }) + } + } + + private func remoteStatus(_ status: String) -> JSONValue { + .object([ + "method": .string("remoteControl/status/changed"), + "params": .object([ + "status": .string(status), "installationId": .string("fixture-installation"), + "serverName": .string("fixture"), + ]), + ]) + } + + @Test(arguments: [false, true]) + func realtimeWorkEndsAtClosureRatherThanStopAcknowledgement(earlyClose: Bool) async throws { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + let creator = UUID() + let closed = JSONValue.object([ + "method": .string("thread/realtime/closed"), "params": threadParams(), + ]) + let gate = fixture.directory.appendingPathComponent("hold-notification-response") + if earlyClose { + try Data().write(to: gate) + try inject(fixture, [closed]) + } + let starting = Task { + try await invoke( + runtime, "thread/realtime/start", + params: .object([ + "threadId": .string("thread_native"), "outputModality": .string("text"), + ]), creator: creator) + } + defer { starting.cancel() } + if earlyClose { + try await until { + await runtime.events(afterCursor: 0, maxResults: 100).objectValue?["events"]?.arrayValue? + .contains { + $0.objectValue?["payload"]?.objectValue?["method"] + == .string("thread/realtime/closed") + } == true + } + try FileManager.default.removeItem(at: gate) + } + _ = try await starting.value + if !earlyClose { + let first = try #require( + try await runtime.workResources().first { $0.kind == "codex.app.realtime" }) + #expect(first.acquiredBy == creator) + await #expect(throws: (any Error).self) { + try await runtime.prepareForHandoff( + threadID: "thread_native", mode: .graceful, interruptActiveTurn: false) + } + _ = try await invoke(runtime, "thread/realtime/stop", params: threadParams()) + #expect(try await runtime.workResources().contains(first)) + try inject(fixture, [closed]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + } + try await until { + try await runtime.workResources().filter { $0.kind == "codex.app.realtime" }.isEmpty + } + } + } + + @Test(arguments: ["thread/queue/start", "review/start"]) + func returnedTurnsRetainTheirCreator(method: String) async throws { + try await withRuntime { _, runtime in + _ = try await invoke(runtime, "thread/start") + let creator = UUID() + var params = ["threadId": JSONValue.string("thread_native")] + if method == "review/start" { + params["target"] = .object(["type": .string("uncommittedChanges")]) + } else { + params["queuedSubmissionId"] = .string("queued-fixture") + } + _ = try await invoke(runtime, method, params: .object(params), creator: creator) + let turn = try #require( + try await runtime.workResources().first { $0.kind == "codex.app.turn" }) + #expect(turn.acquiredBy == creator) + } + } + + @Test(arguments: [false, true]) + func queuedInputOwnsItsEarlyAutomaticTurnAndCallbacks(completed: Bool) async throws { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + _ = try await invoke(runtime, "thread/goal/set", params: threadParams()) + let gate = fixture.directory.appendingPathComponent("hold-notification-response") + try Data().write(to: gate) + var messages = [ + turnNotification("turn/started", id: "turn_native"), + inputRequest(turnID: "turn_native"), queuedUserMessage(), + ] + if completed { messages.append(turnNotification("turn/completed", id: "turn_native")) } + try inject(fixture, messages) + let creator = UUID() + let adding = Task { + try await invoke(runtime, "thread/queue/add", params: queueParams(), creator: creator) + } + defer { adding.cancel() } + try await until { + let method = completed ? "turn/completed" : "item/started" + return await runtime.events(afterCursor: 0, maxResults: 100).objectValue?["events"]? + .arrayValue? + .contains { $0.objectValue?["payload"]?.objectValue?["method"] == .string(method) } + == true + } + try FileManager.default.removeItem(at: gate) + _ = try await adding.value + let resources = try await runtime.workResources() + #expect(resources.filter { $0.kind == "codex.app.queued-input" }.isEmpty) + #expect(resources.first { $0.kind == "codex.app.server-request" }?.acquiredBy == creator) + #expect(resources.filter { $0.kind == "codex.app.turn" }.count == (completed ? 0 : 1)) + #expect( + resources.filter { $0.kind == "codex.app.turn" }.allSatisfy { $0.acquiredBy == creator }) + } + } + + @Test + func explicitQueueStartRetainsTheQueuedCreatorAndDeletionReleasesPendingInput() async throws { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + let creator = UUID() + _ = try await invoke(runtime, "thread/queue/add", params: queueParams(), creator: creator) + let queued = try #require( + try await runtime.workResources().first { $0.kind == "codex.app.queued-input" }) + #expect(queued.acquiredBy == creator) + #expect( + queued.handles == [ + "thread_id": .string("thread_native"), "client_id": .string("queued-client"), + "submission_id": .string("queued-native"), "runtime_id": .string(runtime.runtimeID), + ]) + await #expect(throws: (any Error).self) { + try await invoke(runtime, "thread/queue/add", params: queueParams()) + } + _ = try await invoke( + runtime, "thread/queue/start", + params: .object([ + "threadId": .string("thread_native"), "queuedSubmissionId": .string("queued-native"), + ])) + #expect( + try await runtime.workResources().first { $0.kind == "codex.app.turn" }?.acquiredBy + == creator) + try inject( + fixture, [queuedUserMessage(), turnNotification("turn/completed", id: "turn_native")]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + try await until { try await runtime.workResources().count == 1 } + _ = try await invoke(runtime, "thread/queue/add", params: queueParams()) + _ = try await invoke( + runtime, "thread/queue/delete", + params: .object([ + "threadId": .string("thread_native"), "queuedSubmissionId": .string("queued-native"), + ])) + #expect(try await runtime.workResources().count == 1) + } + } + + private func queueParams() -> JSONValue { + .object([ + "threadId": .string("thread_native"), "clientUserMessageId": .string("queued-client"), + "input": .array([.object(["type": .string("text"), "text": .string("fixture")])]), + ]) + } + + private func queuedUserMessage() -> JSONValue { + .object([ + "method": .string("item/started"), + "params": .object([ + "threadId": .string("thread_native"), "turnId": .string("turn_native"), + "item": .object([ + "type": .string("userMessage"), "id": .string("fixture-user-message"), + "clientId": .string("queued-client"), "content": .array([]), + ]), + ]), + ]) + } + + @Test(arguments: [false, true]) + func detachedReviewBindsEarlyInputWithoutResurrectingACompletedTurn(completed: Bool) async throws + { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + try Data("review-thread".utf8).write( + to: fixture.directory.appendingPathComponent("review-thread-id")) + var input = try #require(inputRequest(turnID: "turn_native").objectValue) + var inputParams = try #require(input["params"]?.objectValue) + inputParams["threadId"] = .string("review-thread") + input["params"] = .object(inputParams) + var messages = [ + turnNotification("turn/started", thread: "review-thread", id: "turn_native"), + JSONValue.object(input), + ] + if completed { + messages.append( + turnNotification("turn/completed", thread: "review-thread", id: "turn_native")) + } + try inject(fixture, messages) + let gate = fixture.directory.appendingPathComponent("hold-notification-response") + try Data().write(to: gate) + let creator = UUID() + let reviewing = Task { + try await invoke( + runtime, "review/start", + params: .object([ + "threadId": .string("thread_native"), "delivery": .string("detached"), + "target": .object(["type": .string("uncommittedChanges")]), + ]), creator: creator) + } + defer { reviewing.cancel() } + try await until { + await runtime.pendingRequests().objectValue?["requests"]?.arrayValue?.count == 1 + } + if completed { + try await until { + await runtime.events(afterCursor: 0, maxResults: 100).objectValue?["events"]?.arrayValue? + .contains { + $0.objectValue?["payload"]?.objectValue?["method"] == .string("turn/completed") + } == true + } + } + try FileManager.default.removeItem(at: gate) + _ = try await reviewing.value + let rows = try await runtime.workResources() + #expect(rows.filter { $0.kind == "codex.app.thread" }.count == 2) + #expect(rows.first { $0.kind == "codex.app.server-request" }?.acquiredBy == creator) + #expect(rows.filter { $0.kind == "codex.app.turn" }.count == (completed ? 0 : 1)) + #expect(rows.filter { $0.kind == "codex.app.turn" }.allSatisfy { $0.acquiredBy == creator }) + } + } + + @Test + func pendingInputBeforeTurnReplyBindsToTheTurnAndSurvivesInvalidResponse() async throws { + try await withRuntime { fixture, runtime in + let threadCreator = UUID() + let turnCreator = UUID() + _ = try await invoke(runtime, "thread/start", creator: threadCreator) + let gate = fixture.directory.appendingPathComponent("hold-notification-response") + try Data().write(to: gate) + try inject( + fixture, + [ + turnNotification("turn/started", id: "turn_native"), + inputRequest(turnID: "turn_native"), + ]) + let turning = Task { + try await invoke(runtime, "turn/start", params: turnParams(), creator: turnCreator) + } + defer { turning.cancel() } + try await until { + await runtime.pendingRequests().objectValue?["requests"]?.arrayValue?.count == 1 + } + await #expect(throws: (any Error).self) { try await runtime.workResources() } + try FileManager.default.removeItem(at: gate) + _ = try await turning.value + let rows = try await runtime.workResources() + #expect(rows.count == 3) + #expect(rows.first { $0.kind == "codex.app.thread" }?.acquiredBy == threadCreator) + #expect( + rows.filter { $0.kind != "codex.app.thread" }.allSatisfy { $0.acquiredBy == turnCreator }) + let input = try #require( + await runtime.pendingRequests().objectValue?["requests"]?.arrayValue?.first) + let id = try #require(input.objectValue?["request_id"]?.stringValue) + #expect( + rows.first { $0.kind == "codex.app.server-request" }?.handles == [ + "request_id": .string(id), "thread_id": .string("thread_native"), + "turn_id": .string("turn_native"), "runtime_id": .string(runtime.runtimeID), + ]) + await #expect(throws: (any Error).self) { + try await runtime.respond(requestID: id, response: .object(["answers": .string("invalid")])) + } + #expect(try await runtime.workResources() == rows) + _ = try await CodexWorkInvocation.$current.withValue(UUID()) { + try await runtime.respond(requestID: id, response: .object(["answers": .object([:])])) + } + #expect( + try await runtime.workResources().filter { $0.kind == "codex.app.server-request" }.isEmpty) + #expect( + try await runtime.workResources().first { $0.kind == "codex.app.turn" }?.acquiredBy + == turnCreator) + } + } + + @Test(arguments: [false, true]) + func completionNotificationsRespectTheExactTurn(completeNewTurn: Bool) async throws { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + try Data("old-turn".utf8).write( + to: fixture.directory.appendingPathComponent("created-turn-id")) + _ = try await invoke(runtime, "turn/start", params: turnParams()) + try Data("new-turn".utf8).write( + to: fixture.directory.appendingPathComponent("created-turn-id")) + let gate = fixture.directory.appendingPathComponent("hold-notification-response") + try Data().write(to: gate) + let completionID = completeNewTurn ? "new-turn" : "old-turn" + try inject(fixture, [turnNotification("turn/completed", id: completionID)]) + let creator = UUID() + let turning = Task { + try await invoke(runtime, "turn/start", params: turnParams(), creator: creator) + } + defer { turning.cancel() } + try await until { + let events = await runtime.events(afterCursor: 0, maxResults: 100) + return events.objectValue?["events"]?.arrayValue?.contains { + $0.objectValue?["payload"]?.objectValue?["method"] == .string("turn/completed") + } == true + } + try FileManager.default.removeItem(at: gate) + _ = try await turning.value + let turns = try await runtime.workResources().filter { $0.kind == "codex.app.turn" } + #expect(turns.count == (completeNewTurn ? 0 : 1)) + if !completeNewTurn { #expect(turns.first?.acquiredBy == creator) } + } + } + + @Test + func activeGoalOwnsDerivedTurnsButReadingStoredGoalsDoesNotAcquireWork() async throws { + try await withRuntime { fixture, runtime in + try Data("thread_fixture".utf8).write( + to: fixture.directory.appendingPathComponent("created-thread-id")) + let threadCreator = UUID() + let goalCreator = UUID() + _ = try await invoke(runtime, "thread/start", creator: threadCreator) + let thread = threadParams("thread_fixture") + _ = try await invoke(runtime, "thread/goal/get", params: thread) + #expect(try await runtime.workResources().count == 1) + _ = try await invoke(runtime, "thread/goal/set", params: thread, creator: goalCreator) + #expect( + try await runtime.workResources().first { $0.kind == "codex.app.goal" }?.acquiredBy + == goalCreator) + try inject( + fixture, [turnNotification("turn/started", thread: "thread_fixture", id: "goal-turn")]) + _ = try await invoke(runtime, "thread/goal/get", params: thread) + try await until { + await runtime.status().objectValue?["threads"]?.arrayValue?.first?.objectValue?[ + "active_turn_id"] == .string("goal-turn") + } + let rows = try await runtime.workResources() + #expect(rows.count == 3) + #expect( + rows.filter { $0.kind != "codex.app.thread" }.allSatisfy { $0.acquiredBy == goalCreator }) + _ = try await invoke(runtime, "thread/goal/clear", params: thread) + #expect(try await runtime.workResources().filter { $0.kind == "codex.app.goal" }.isEmpty) + #expect( + try await runtime.workResources().first { $0.kind == "codex.app.turn" }?.acquiredBy + == goalCreator) + } + } + + @Test(arguments: [false, true]) + func goalTurnBeforeMutationReplyKeepsTheGoalCreator(inputBeforeTurn: Bool) async throws { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + let creator = UUID() + let gate = fixture.directory.appendingPathComponent("hold-notification-response") + try Data().write(to: gate) + let messages = + inputBeforeTurn + ? [inputRequest(turnID: "goal-turn")] + : [turnNotification("turn/started", id: "goal-turn")] + try inject(fixture, messages) + let starting = Task { + try await invoke(runtime, "thread/goal/set", params: threadParams(), creator: creator) + } + defer { starting.cancel() } + if inputBeforeTurn { + try await until { + await runtime.pendingRequests().objectValue?["requests"]?.arrayValue?.count == 1 + } + } else { + try await until { + await runtime.status().objectValue?["threads"]?.arrayValue?.first?.objectValue?[ + "active_turn_id"] == .string("goal-turn") + } + } + try FileManager.default.removeItem(at: gate) + _ = try await starting.value + if inputBeforeTurn { + try inject(fixture, [turnNotification("turn/started", id: "goal-turn")]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + try await until { + await runtime.status().objectValue?["threads"]?.arrayValue?.first?.objectValue?[ + "active_turn_id"] == .string("goal-turn") + } + } + let work = try await runtime.workResources().filter { $0.kind != "codex.app.thread" } + let expected: Set = + inputBeforeTurn + ? ["codex.app.goal", "codex.app.turn", "codex.app.server-request"] + : ["codex.app.goal", "codex.app.turn"] + #expect(Set(work.map(\.kind)) == expected) + #expect(work.allSatisfy { $0.acquiredBy == creator }) + } + } + + @Test(arguments: ["thread/goal/clear", "thread/goal/get"]) + func goalResponseCannotDiscardAnInterleavedActiveNotification(method: String) async throws { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + let creator = UUID() + _ = try await invoke(runtime, "thread/goal/set", params: threadParams(), creator: creator) + let first = try #require( + try await runtime.workResources().first { $0.kind == "codex.app.goal" }) + try Data("complete".utf8).write( + to: fixture.directory.appendingPathComponent("goal-read-status")) + let gate = fixture.directory.appendingPathComponent("hold-notification-response") + try Data().write(to: gate) + try inject(fixture, [goalNotification()]) + let querying = Task { try await invoke(runtime, method, params: threadParams()) } + defer { querying.cancel() } + try await until { + let events = await runtime.events(afterCursor: 0, maxResults: 100) + return events.objectValue?["events"]?.arrayValue?.contains { + $0.objectValue?["payload"]?.objectValue?["method"] == .string("thread/goal/updated") + } == true + } + try FileManager.default.removeItem(at: gate) + _ = try await querying.value + let remaining = try await runtime.workResources().filter { $0.kind == "codex.app.goal" } + #expect(remaining.count == 1) + #expect(remaining.first?.id == first.id) + #expect(remaining.first?.acquiredBy == creator) + if method == "thread/goal/clear" { #expect(remaining.first?.state == .uncertain) } + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + #expect(try await runtime.workResources().filter { $0.kind == "codex.app.goal" }.isEmpty) + } + } + + private func goalNotification() -> JSONValue { + .object([ + "method": .string("thread/goal/updated"), + "params": .object([ + "threadId": .string("thread_native"), + "goal": .object([ + "threadId": .string("thread_native"), "status": .string("active"), + "objective": .string("fixture"), "createdAt": .integer(1), "updatedAt": .integer(2), + "timeUsedSeconds": .integer(0), "tokensUsed": .integer(0), + ]), + ]), + ]) + } + + @Test + func canceledAndReplacedLoginsKeepOwnersUntilTheirExactCompletion() async throws { + try await withRuntime { fixture, runtime in + let firstCreator = UUID() + let secondCreator = UUID() + let firstID = "11111111-1111-1111-1111-111111111111" + let secondID = UUID().uuidString + let params = JSONValue.object(["type": .string("chatgpt")]) + _ = try await invoke(runtime, "account/login/start", params: params, creator: firstCreator) + let first = try #require(try await runtime.workResources().first) + #expect(first.handles["login_id"] == .string(firstID)) + _ = try await invoke( + runtime, "account/login/cancel", params: .object(["loginId": .string(firstID)])) + #expect(try await runtime.workResources() == [first]) + try Data(secondID.utf8).write(to: fixture.directory.appendingPathComponent("login-id")) + _ = try await invoke(runtime, "account/login/start", params: params, creator: secondCreator) + #expect( + Set(try await runtime.workResources().map(\.acquiredBy)) == [firstCreator, secondCreator]) + try inject(fixture, [loginCompletion(id: firstID)]) + _ = try await invoke( + runtime, "account/login/cancel", params: .object(["loginId": .string(firstID)])) + try await until { try await runtime.workResources().count == 1 } + #expect(try await runtime.workResources().first?.acquiredBy == secondCreator) + #expect(try await runtime.workResources().first?.handles["login_id"] == .string(secondID)) + try inject(fixture, [loginCompletion(id: secondID)]) + _ = try await invoke( + runtime, "account/login/cancel", params: .object(["loginId": .string(secondID)])) + try await until { try await runtime.workResources().isEmpty } + } + } + + @Test(arguments: [false, true]) + func earlyLoginCompletionDoesNotLeaveWorkAfterTheReply(mcp: Bool) async throws { + try await withRuntime { fixture, runtime in + let method = mcp ? "mcpServer/oauth/login" : "account/login/start" + let completion = + mcp + ? mcpLoginCompletion(threadID: nil) + : loginCompletion(id: "11111111-1111-1111-1111-111111111111") + let params = JSONValue.object( + mcp ? ["name": .string("fixture")] : ["type": .string("chatgptDeviceCode")]) + let gate = fixture.directory.appendingPathComponent("hold-notification-response") + try Data().write(to: gate) + try inject(fixture, [completion]) + let starting = Task { try await invoke(runtime, method, params: params) } + defer { starting.cancel() } + try await until { + await runtime.events(afterCursor: 0, maxResults: 100).objectValue?["events"]?.arrayValue? + .contains { + $0.objectValue?["payload"]?.objectValue?["method"] == completion.objectValue?["method"] + } == true + } + try FileManager.default.removeItem(at: gate) + _ = try await starting.value + #expect(try await runtime.workResources().isEmpty) + } + } + + @Test + func mcpLoginCompletionMatchesServerAndThreadBeforeAllowingAnotherAttempt() async throws { + try await withRuntime { fixture, runtime in + _ = try await invoke(runtime, "thread/start") + let globalCreator = UUID() + let threadCreator = UUID() + let globalParams = JSONValue.object(["name": .string("fixture")]) + _ = try await invoke( + runtime, "mcpServer/oauth/login", params: globalParams, creator: globalCreator) + await #expect(throws: (any Error).self) { + try await invoke(runtime, "mcpServer/oauth/login", params: globalParams) + } + _ = try await invoke( + runtime, "mcpServer/oauth/login", + params: .object([ + "name": .string("fixture"), "threadId": .string("thread_native"), + ]), creator: threadCreator) + try inject(fixture, [mcpLoginCompletion(threadID: nil)]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + try await until { + try await runtime.workResources().filter { $0.kind == "codex.app.mcp-login" }.count == 1 + } + #expect( + try await runtime.workResources().first { $0.kind == "codex.app.mcp-login" }?.acquiredBy + == threadCreator) + _ = try await invoke(runtime, "mcpServer/oauth/login", params: globalParams) + #expect( + try await runtime.workResources().filter { $0.kind == "codex.app.mcp-login" }.count == 2) + await runtime.shutdown() + #expect(try await runtime.workResources().isEmpty) + } + } + + private func loginCompletion(id: String) -> JSONValue { + .object([ + "method": .string("account/login/completed"), + "params": .object(["loginId": .string(id), "success": .bool(false), "error": .null]), + ]) + } + + private func mcpLoginCompletion(threadID: String?) -> JSONValue { + .object([ + "method": .string("mcpServer/oauthLogin/completed"), + "params": .object([ + "name": .string("fixture"), "threadId": threadID.map(JSONValue.string) ?? .null, + "success": .bool(false), "error": .null, + ]), + ]) + } + + @Test + func unboundHistoricalThreadsRemainUnavailableAndAreNotClaimedByReaders() async throws { + try await withRuntime { fixture, runtime in + try Data("thread_fixture".utf8).write( + to: fixture.directory.appendingPathComponent("created-thread-id")) + _ = try await invoke(runtime, "thread/loaded/list") + await #expect(throws: (any Error).self) { try await runtime.workResources() } + _ = try await invoke(runtime, "thread/goal/get", params: threadParams("thread_fixture")) + await #expect(throws: (any Error).self) { try await runtime.workResources() } + _ = try await invoke(runtime, "thread/resume", params: threadParams("thread_fixture")) + await #expect(throws: (any Error).self) { try await runtime.workResources() } + await runtime.shutdown() + #expect(try await runtime.workResources().isEmpty) + } + } + + @Test + func nativeThreadActivityKeepsItsParentOriginRatherThanTheObserver() async throws { + try await withRuntime { fixture, runtime in + let creator = UUID() + _ = try await invoke(runtime, "thread/start", creator: creator) + try inject( + fixture, + [ + turnNotification("turn/started", id: "native-turn"), + inputRequest(turnID: "native-turn"), + ]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams(), creator: UUID()) + try await until { + await runtime.pendingRequests().objectValue?["requests"]?.arrayValue?.count == 1 + } + let resources = try await runtime.workResources() + #expect( + Set(resources.map(\.kind)) == [ + "codex.app.thread", "codex.app.turn", "codex.app.server-request", + ]) + #expect(resources.allSatisfy { $0.acquiredBy == creator }) + } + } + + @Test + func pendingStartupIsOwnedUntilCancellationCleanup() async throws { + try await withRuntime { fixture, runtime in + try Data().write(to: fixture.hangInitializeFile) + let creator = UUID() + let starting = Task { try await invoke(runtime, "thread/start", creator: creator) } + defer { starting.cancel() } + _ = try await fixture.waitForLatestPID(count: 1) + let rows = try await runtime.workResources() + #expect(Set(rows.map(\.kind)) == ["codex.app.call", "codex.app.startup"]) + #expect(rows.allSatisfy { $0.acquiredBy == creator }) + starting.cancel() + _ = await starting.result + await runtime.shutdown() + #expect(try await runtime.workResources().isEmpty) + } + } + + @Test + func hostCallbackRemainsOwnedAfterTurnAndNativeProcessComplete() async throws { + let fixture = try AppServerProcessFixture() + defer { fixture.remove() } + let host = BlockingWorkHost() + let runtime = fixture.makeRuntime( + workspaceID: fixture.directory.lastPathComponent, dynamicToolDispatcher: host) + do { + _ = try await invoke(runtime, "thread/start") + let creator = UUID() + _ = try await invoke(runtime, "turn/start", params: turnParams(), creator: creator) + try inject( + fixture, [dynamicRequest(), turnNotification("turn/completed", id: "turn_native")]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + try await until { await host.started } + await runtime.shutdown() + let remaining = try await runtime.workResources() + #expect(remaining.count == 1) + #expect(remaining.first?.kind == "codex.app.server-request") + #expect(remaining.first?.acquiredBy == creator) + await host.finish() + try await until { try await runtime.workResources().isEmpty } + } catch { + await host.finish() + await runtime.shutdown() + throw error + } + } + + @Test + func reusedRequestIDInANewConnectionDoesNotReplaceAnUnfinishedHostCallback() async throws { + let fixture = try AppServerProcessFixture() + defer { fixture.remove() } + let host = BlockingWorkHost() + let runtime = fixture.makeRuntime( + workspaceID: fixture.directory.lastPathComponent, dynamicToolDispatcher: host) + do { + _ = try await invoke(runtime, "thread/start") + let firstCreator = UUID() + _ = try await invoke(runtime, "turn/start", params: turnParams(), creator: firstCreator) + try inject(fixture, [dynamicRequest()]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + try await until { await host.count == 1 } + let process = try await fixture.waitForLatestPID(count: 1) + #expect(Darwin.kill(process, SIGTERM) == 0) + try await until { + let status = await runtime.status().objectValue + return status?["connection_state"] != .string("running") + && status?["process"]?.objectValue?["cleanup_confirmed"] == .bool(true) + } + _ = try await invoke(runtime, "thread/start") + let secondCreator = UUID() + _ = try await invoke(runtime, "turn/start", params: turnParams(), creator: secondCreator) + try inject(fixture, [dynamicRequest()]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + try await until { await host.count == 2 } + let callbacks = try await runtime.workResources().filter { + $0.kind == "codex.app.server-request" + } + #expect(callbacks.count == 2) + #expect(Set(callbacks.map(\.acquiredBy)) == [firstCreator, secondCreator]) + await runtime.shutdown() + #expect(try await runtime.workResources().count == 2) + await host.finish() + try await until { try await runtime.workResources().isEmpty } + } catch { + await host.finish() + await runtime.shutdown() + throw error + } + } + + @Test + func queuedHostRequestKeepsItsOriginWhenTurnCompletionOvertakesItsConsumer() async throws { + let fixture = try AppServerProcessFixture() + defer { fixture.remove() } + let host = BlockingWorkHost() + let runtime = fixture.makeRuntime( + workspaceID: fixture.directory.lastPathComponent, dynamicToolDispatcher: host) + do { + _ = try await invoke(runtime, "thread/start") + let creator = UUID() + _ = try await invoke(runtime, "turn/start", params: turnParams(), creator: creator) + try inject(fixture, [dynamicRequest()]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + try await until { await host.totalStarted == 1 } + try inject( + fixture, [dynamicRequest(id: 901), turnNotification("turn/completed", id: "turn_native")]) + _ = try await invoke(runtime, "thread/goal/get", params: threadParams()) + try await until { + await runtime.status().objectValue?["threads"]?.arrayValue?.first?.objectValue?[ + "active_turn_id"] == .null + } + try await until { await host.totalStarted == 2 } + let callbacks = try await runtime.workResources().filter { + $0.kind == "codex.app.server-request" + } + #expect(callbacks.count == 2) + #expect(callbacks.allSatisfy { $0.acquiredBy == creator }) + await host.finish() + await runtime.shutdown() + try await until { try await runtime.workResources().isEmpty } + } catch { + await host.finish() + await runtime.shutdown() + throw error + } + } + + @Test + func callbackCapacityRejectsNewRequestsWithoutEvictingUnfinishedOwners() async throws { + let fixture = try AppServerProcessFixture() + defer { fixture.remove() } + let host = BlockingWorkHost() + let runtime = fixture.makeRuntime( + workspaceID: fixture.directory.lastPathComponent, dynamicToolDispatcher: host) + do { + _ = try await invoke(runtime, "thread/start") + let creator = UUID() + _ = try await invoke(runtime, "turn/start", params: turnParams(), creator: creator) + let requests = (900...1156).map { dynamicRequest(id: Int64($0)) } + for offset in stride(from: 0, to: requests.count, by: 16) { + let end = min(offset + 16, requests.count) + let completion = + end == requests.count + ? [turnNotification("turn/completed", id: "turn_native")] : [] + try inject(fixture, Array(requests[offset.. JSONValue { + .object([ + "id": .integer(900), "method": .string("item/tool/requestUserInput"), + "params": .object([ + "threadId": .string("thread_native"), "turnId": .string(turnID), + "itemId": .string("input"), "isBlocking": .bool(true), + "questions": .array([ + .object([ + "id": .string("confirm"), "header": .string("Confirm"), + "question": .string("Proceed?"), + ]) + ]), + ]), + ]) + } + + private func dynamicRequest(id: Int64 = 900) -> JSONValue { + .object([ + "id": .integer(id), "method": .string("item/tool/call"), + "params": .object([ + "callId": .string("fixture-callback"), "threadId": .string("thread_native"), + "turnId": .string("turn_native"), "namespace": .string("computer-mcp"), + "tool": .string("fixture.read"), "arguments": .object([:]), + ]), + ]) + } + + private func invoke( + _ runtime: LiveCodexAppServerRuntime, _ method: String, + params: JSONValue = .object([:]), creator: UUID = UUID() + ) async throws -> JSONValue { + try await CodexWorkInvocation.$current.withValue(creator) { + try await runtime.call(method: method, params: params) + } + } + + private func threadParams(_ id: String = "thread_native") -> JSONValue { + .object(["threadId": .string(id)]) + } + + private func turnParams() -> JSONValue { + .object([ + "threadId": .string("thread_native"), + "input": .array([.object(["type": .string("text"), "text": .string("fixture")])]), + ]) + } + + private func turnNotification(_ method: String, thread: String = "thread_native", id: String) + -> JSONValue + { + .object([ + "method": .string(method), + "params": .object([ + "threadId": .string(thread), + "turn": .object([ + "id": .string(id), "items": .array([]), + "status": .string(method == "turn/started" ? "inProgress" : "completed"), + ]), + ]), + ]) + } + + private func inject(_ fixture: AppServerProcessFixture, _ messages: [JSONValue]) throws { + let data = try messages.reduce(into: Data()) { data, message in + data.append(try JSONEncoder().encode(message)) + data.append(10) + } + try data.write(to: fixture.directory.appendingPathComponent("notifications-next.jsonl")) + } + + private func until(_ condition: () async throws -> Bool) async throws { + for _ in 0..<500 { + if try await condition() { return } + try await Task.sleep(for: .milliseconds(10)) + } + throw CodexToolError.executionFailed("Timed out waiting for the isolated fixture observation.") + } + + private func withRuntime( + _ body: (AppServerProcessFixture, LiveCodexAppServerRuntime) async throws -> Void + ) async throws { + let fixture = try AppServerProcessFixture() + defer { fixture.remove() } + let runtime = fixture.makeRuntime( + requestTimeoutSeconds: 10, workspaceID: fixture.directory.lastPathComponent) + do { + try await body(fixture, runtime) + await runtime.shutdown() + } catch { + try? FileManager.default.removeItem( + at: fixture.directory.appendingPathComponent("hold-notification-response")) + await runtime.shutdown() + throw error + } + } +} + +private actor BlockingWorkHost: CodexHostTools { + private(set) var started = false + private(set) var totalStarted = 0 + private var continuations: [CheckedContinuation] = [] + var count: Int { continuations.count } + func risk(named name: String, arguments: JSONValue, requestID: String, workspaceID: String?) + -> CodexOperationRisk + { .readOnly } + func execute(name: String, arguments: JSONValue, requestID: String, workspaceID: String?) async + -> JSONValue + { + await withCheckedContinuation { continuation in + continuations.append(continuation) + started = true + totalStarted += 1 + } + return .object(["done": .bool(true)]) + } + func finish() { + let pending = continuations + continuations.removeAll() + for continuation in pending { continuation.resume() } + } +} diff --git a/Tests/CodexAdapterTests/CodexEventBufferTests.swift b/Tests/CodexAdapterTests/CodexEventBufferTests.swift index 610d589..3839f4e 100644 --- a/Tests/CodexAdapterTests/CodexEventBufferTests.swift +++ b/Tests/CodexAdapterTests/CodexEventBufferTests.swift @@ -1,3 +1,4 @@ +import CodexAppServerProtocol import Foundation import Testing @@ -68,4 +69,105 @@ final class CodexEventBufferTests { #expect(encoded.contains("[REDACTED]")) #expect(!encoded.contains("event-secret")) } + + @Test + func nativeUsageCountersSurviveEventRetentionExactly() async throws { + let usage = CodexAppServerProtocol.Stable.TokenUsageBreakdown( + cacheWriteInputTokens: 2, + cachedInputTokens: 3, + inputTokens: 9_007_199_254_740_993, + outputTokens: 5, + reasoningOutputTokens: 7, + totalTokens: Int64.max + ) + let native = try JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(usage)) + let execEncoder = JSONEncoder() + execEncoder.keyEncodingStrategy = .convertToSnakeCase + let exec = try JSONDecoder().decode(JSONValue.self, from: execEncoder.encode(usage)) + let buffer = CodexEventBuffer(capacity: 8, maxOutputBytes: 65_536) + await buffer.append(kind: "turn.completed", payload: .object(["usage": exec])) + await buffer.append( + kind: "notification", + payload: .object([ + "method": .string("thread/tokenUsage/updated"), + "params": .object([ + "tokenUsage": .object([ + "last": native, "total": native, "modelContextWindow": .integer(Int64.max), + ]) + ]), + ])) + await buffer.append( + kind: "goal/updated", + payload: .object(["tokenBudget": .null, "tokensUsed": .integer(9_007_199_254_740_993)])) + + let result = await buffer.read(afterCursor: 0, maxResults: 10) + let events = try #require(result.objectValue?["events"]?.arrayValue) + #expect(events.count == 3) + #expect(events[0].objectValue?["payload"]?.objectValue?["usage"] == exec) + let retained = try #require( + events[1].objectValue?["payload"]?.objectValue?["params"]?.objectValue?["tokenUsage"]? + .objectValue?["total"]) + #expect( + try JSONDecoder().decode( + CodexAppServerProtocol.Stable.TokenUsageBreakdown.self, + from: JSONEncoder().encode(retained)) == usage) + #expect(events[2].objectValue?["payload"]?.objectValue?["tokenBudget"] == .null) + #expect( + events[2].objectValue?["payload"]?.objectValue?["tokensUsed"] + == .integer(9_007_199_254_740_993)) + } + + @Test + func usageContainersStillRedactCredentialsAndInvalidCounters() async throws { + let buffer = CodexEventBuffer(capacity: 8, maxOutputBytes: 65_536) + await buffer.append( + kind: "notification", + payload: .object([ + "tokenUsage": .object([ + "accessToken": .string("nested-secret"), + "last": .object([ + "inputTokens": .string("counter-secret"), + "outputTokens": .integer(3), + "tokenBudget": .object(["credential": .string("budget-secret")]), + ]), + ]), + "token_usage": .string("container-secret"), + "refresh_token": .integer(123_456), + "password": .integer(987_654), + "authorization": .string("Bearer auth-secret"), + ])) + let result = await buffer.read(afterCursor: 0, maxResults: 10) + let payload = try #require( + result.objectValue?["events"]?.arrayValue?.first?.objectValue?["payload"]?.objectValue) + let usage = try #require(payload["tokenUsage"]?.objectValue) + #expect(usage["accessToken"] == .string("[REDACTED]")) + #expect(usage["last"]?.objectValue?["inputTokens"] == .string("[REDACTED]")) + #expect(usage["last"]?.objectValue?["outputTokens"] == .integer(3)) + #expect(usage["last"]?.objectValue?["tokenBudget"] == .string("[REDACTED]")) + for key in ["token_usage", "refresh_token", "password", "authorization"] { + #expect(payload[key] == .string("[REDACTED]")) + } + let encoded = String(decoding: try JSONEncoder().encode(result), as: UTF8.self) + #expect(!encoded.contains("secret")) + #expect(!encoded.contains("123456")) + #expect(!encoded.contains("987654")) + } + + @Test(arguments: ["api_key", "apiKey", "api-key", "APIKEY"]) + func usageContainersRedactAPIKeys(key: String) async throws { + let buffer = CodexEventBuffer(capacity: 8, maxOutputBytes: 65_536) + await buffer.append( + kind: "notification", + payload: .object([ + "tokenUsage": .object([ + key: .string("api-credential-fixture"), "inputTokens": .integer(3), + ]) + ])) + let result = await buffer.read(afterCursor: 0, maxResults: 10) + let usage = try #require( + result.objectValue?["events"]?.arrayValue?.first?.objectValue?["payload"]?.objectValue?[ + "tokenUsage"]?.objectValue) + #expect(usage[key] == .string("[REDACTED]")) + #expect(usage["inputTokens"] == .integer(3)) + } } diff --git a/Tests/CodexAdapterTests/CodexExecRuntimeTests.swift b/Tests/CodexAdapterTests/CodexExecRuntimeTests.swift index 78ddfdc..6d9f09d 100644 --- a/Tests/CodexAdapterTests/CodexExecRuntimeTests.swift +++ b/Tests/CodexAdapterTests/CodexExecRuntimeTests.swift @@ -354,6 +354,282 @@ final class CodexExecRuntimeTests { #expect(result.objectValue?["upstream_session_id"] == .string("native-session")) } + @Test(arguments: [false, true]) + func shutdownOwnsPendingLaunchAndCleansLateHandle(resume: Bool) async throws { + let events = AsyncStream.makeStream() + let client = PendingExecClient(events: events.continuation) + let runtime = LiveCodexExecRuntime( + configuration: configuration(), workspaceURL: URL(fileURLWithPath: "/tmp"), client: client) + var observations = events.stream.makeAsyncIterator() + let launch = Task { + if resume { + return try await runtime.resume(upstreamSessionID: "fixture", prompt: "fixture") + } + return try await runtime.start(prompt: "fixture") + } + #expect(await observations.next() == "launch-entered") + let shutdown = Task { + await runtime.shutdown() + events.continuation.yield("shutdown-returned") + } + #expect(await observations.next() == "launch-cancelled") + await client.release() + let started = try await launch.value + await shutdown.value + let sessionID = try requiredString("session_id", in: started) + let rows = await runtime.list().objectValue?["sessions"]?.arrayValue ?? [] + #expect(rows.first?.objectValue?["state"] == .string("cancelled")) + #expect(rows.first?.objectValue?["cleanup_confirmed"] == .bool(true)) + _ = try? await runtime.cancel(sessionID: sessionID) + _ = try await waitForResult(runtime: runtime, sessionID: sessionID) + } + + @Test + func shutdownRejectsNewExecutionBeforeNativeLaunch() async throws { + let client = FakeCodexExecClientAdapter(handles: []) + let runtime = LiveCodexExecRuntime( + configuration: configuration(), workspaceURL: URL(fileURLWithPath: "/tmp"), client: client) + await runtime.shutdown() + for resume in [false, true] { + do { + if resume { + _ = try await runtime.resume(upstreamSessionID: "fixture", prompt: "fixture") + } else { + _ = try await runtime.start(prompt: "fixture") + } + Issue.record("Stopped runtime admitted execution") + } catch { + #expect((error as? CodexExecRuntimeError)?.code == "codex.exec.stopped") + } + } + #expect(await client.runRequests.isEmpty) + #expect(await client.resumeRequests.isEmpty) + } + + @Test + func stoppedRuntimeCannotClaimNativeThreadOwnership() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let index = try CodexThreadOwnerIndex( + path: root.appendingPathComponent("owners.sqlite").path, subject: "fixture", codexHome: root) + let client = FakeCodexExecClientAdapter(handles: []) + let runtime = LiveCodexExecRuntime( + configuration: configuration(), workspaceURL: root, client: client, threadOwnerIndex: index) + await runtime.shutdown() + _ = try? await runtime.resume(upstreamSessionID: "unclaimed", prompt: "fixture") + #expect(try !index.owns(threadID: "unclaimed")) + #expect(await client.resumeRequests.isEmpty) + } + + @Test + func cancellingStartupCleansLateHandleBeforeReturning() async throws { + let events = AsyncStream.makeStream() + let client = PendingExecClient(events: events.continuation) + let runtime = LiveCodexExecRuntime( + configuration: configuration(), workspaceURL: URL(fileURLWithPath: "/tmp"), client: client) + var observations = events.stream.makeAsyncIterator() + let launch = Task { try await runtime.start(prompt: "fixture") } + #expect(await observations.next() == "launch-entered") + launch.cancel() + #expect(await observations.next() == "launch-cancelled") + await client.release() + let result = try await launch.value + #expect(result.objectValue?["state"] == .string("cancelled")) + #expect(result.objectValue?["cleanup_confirmed"] == .bool(true)) + await runtime.shutdown() + } + + @Test + func failedLaunchReleasesItsReservedCapacity() async throws { + let events = AsyncStream.makeStream() + let client = PendingExecClient( + events: events.continuation, + failure: CodexExecRuntimeError(code: "test.launch_failed", message: "No process started")) + let runtime = LiveCodexExecRuntime( + configuration: configuration(maxSessions: 1), workspaceURL: URL(fileURLWithPath: "/tmp"), + client: client) + var observations = events.stream.makeAsyncIterator() + for _ in 0..<2 { + let launch = Task { try await runtime.start(prompt: "fixture") } + #expect(await observations.next() == "launch-entered") + await assertThrowsErrorAsync(try await runtime.start(prompt: "blocked")) { error in + #expect((error as? CodexExecRuntimeError)?.code == "codex.exec.session_limit") + } + await client.release() + await assertThrowsErrorAsync(try await launch.value) { error in + #expect((error as? CodexExecRuntimeError)?.code == "test.launch_failed") + } + } + #expect(await runtime.list().objectValue?["sessions"] == .array([])) + await runtime.shutdown() + } + + @Test + func retainedWorkKeepsItsCreatorUntilExplicitRelease() async throws { + let origin = UUID() + let runtime = LiveCodexExecRuntime( + configuration: configuration(), workspaceURL: URL(fileURLWithPath: "/tmp"), + client: FakeCodexExecClientAdapter(handles: [ControllableCodexExecHandle()])) + let started = try await CodexWorkInvocation.$current.withValue(origin) { + try await runtime.start(prompt: "fixture") + } + let id = try requiredString("session_id", in: started) + let expected = try CodexWorkResource(kind: "codex.exec.session", id: id, acquiredBy: origin) + #expect(try await runtime.workResources() == [expected]) + await assertThrowsErrorAsync(try await runtime.release(sessionID: id)) { error in + #expect((error as? CodexExecRuntimeError)?.code == "codex.exec.not_finished") + } + _ = try await CodexWorkInvocation.$current.withValue(UUID()) { + _ = try await runtime.events(sessionID: id, afterCursor: 0, maxResults: 100) + return try await runtime.cancel(sessionID: id) + } + await runtime.shutdown() + #expect( + try await runtime.result(sessionID: id).objectValue?["cleanup_confirmed"] == .bool(true)) + #expect(try await runtime.workResources() == [expected]) + let released = try await runtime.release(sessionID: id) + #expect(released.objectValue?["released"] == .bool(true)) + #expect(try await runtime.workResources().isEmpty) + await assertThrowsErrorAsync(try await runtime.result(sessionID: id)) { error in + #expect((error as? CodexExecRuntimeError)?.code == "codex.exec.session_unknown") + } + await assertThrowsErrorAsync(try await runtime.release(sessionID: id)) { error in + #expect((error as? CodexExecRuntimeError)?.code == "codex.exec.session_unknown") + } + } + + @Test(arguments: [false, true]) + func workCoversPendingLaunchAndLateHandle(resume: Bool) async throws { + let events = AsyncStream.makeStream() + let client = PendingExecClient(events: events.continuation) + let origin = UUID() + let runtime = LiveCodexExecRuntime( + configuration: configuration(), workspaceURL: URL(fileURLWithPath: "/tmp"), client: client) + var observations = events.stream.makeAsyncIterator() + let launch = Task { + try await CodexWorkInvocation.$current.withValue(origin) { + if resume { return try await runtime.resume(upstreamSessionID: "fixture", prompt: nil) } + return try await runtime.start(prompt: "fixture") + } + } + #expect(await observations.next() == "launch-entered") + let pending = try #require(try await runtime.workResources().first) + #expect(pending.acquiredBy == origin) + #expect(pending.state == .active) + launch.cancel() + #expect(await observations.next() == "launch-cancelled") + #expect(try await runtime.workResources() == [pending]) + await client.release() + let result = try await launch.value + #expect(result.objectValue?["session_id"] == .string(pending.id)) + #expect(result.objectValue?["cleanup_confirmed"] == .bool(true)) + #expect(try await runtime.workResources() == [pending]) + _ = try await runtime.release(sessionID: pending.id) + #expect(try await runtime.workResources().isEmpty) + await runtime.shutdown() + } + + @Test + func concurrentCreatorsRemainDistinct() async throws { + let runtime = LiveCodexExecRuntime( + configuration: configuration(), workspaceURL: URL(fileURLWithPath: "/tmp"), + client: FakeCodexExecClientAdapter(handles: [ + ControllableCodexExecHandle(), ControllableCodexExecHandle(), + ])) + let firstOrigin = UUID() + let secondOrigin = UUID() + async let first = CodexWorkInvocation.$current.withValue(firstOrigin) { + try await runtime.start(prompt: "first") + } + async let second = CodexWorkInvocation.$current.withValue(secondOrigin) { + try await runtime.resume(upstreamSessionID: "fixture", prompt: "second") + } + let firstID = try requiredString("session_id", in: await first) + let secondID = try requiredString("session_id", in: await second) + let owners = Dictionary( + uniqueKeysWithValues: try await runtime.workResources().map { + ($0.id, $0.acquiredBy) + }) + #expect(owners == [firstID: firstOrigin, secondID: secondOrigin]) + await runtime.shutdown() + _ = try await runtime.release(sessionID: firstID) + #expect(try await runtime.workResources().map(\.id) == [secondID]) + _ = try await runtime.release(sessionID: secondID) + } + + @Test + func uncertainCleanupCannotBeReleasedOrEvicted() async throws { + let origin = UUID() + let client = FakeCodexExecClientAdapter(handles: [ + FakeCodexExecHandle( + lines: [], error: CodexExecError.launchFailure(description: "unknown cleanup")) + ]) + let runtime = LiveCodexExecRuntime( + configuration: configuration(maxSessions: 1), workspaceURL: URL(fileURLWithPath: "/tmp"), + client: client) + let started = try await CodexWorkInvocation.$current.withValue(origin) { + try await runtime.start(prompt: "fixture") + } + let id = try requiredString("session_id", in: started) + _ = try await waitForResult(runtime: runtime, sessionID: id) + await assertThrowsErrorAsync(try await runtime.start(prompt: "blocked")) { error in + #expect((error as? CodexExecRuntimeError)?.code == "codex.exec.session_limit") + } + await runtime.shutdown() + await assertThrowsErrorAsync(try await runtime.release(sessionID: id)) { error in + #expect((error as? CodexExecRuntimeError)?.code == "codex.exec.cleanup_unconfirmed") + } + #expect( + try await runtime.workResources() == [ + try CodexWorkResource( + kind: "codex.exec.session", id: id, acquiredBy: origin, state: .uncertain) + ]) + #expect(await client.runRequests.count == 1) + } + + @Test + func releasingCompletedResultPreservesNativeThreadOwnership() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let index = try CodexThreadOwnerIndex( + path: root.appendingPathComponent("owners.sqlite").path, subject: "fixture", codexHome: root) + let runtime = LiveCodexExecRuntime( + configuration: configuration(), workspaceURL: root, + client: FakeCodexExecClientAdapter(handles: [ + FakeCodexExecHandle( + lines: [#"{"type":"thread.started","thread_id":"native-history"}"#], + termination: successfulTermination(workspace: root, operation: .run)) + ]), threadOwnerIndex: index) + let started = try await CodexWorkInvocation.$current.withValue(UUID()) { + try await runtime.start(prompt: "fixture") + } + let id = try requiredString("session_id", in: started) + _ = try await waitForResult(runtime: runtime, sessionID: id) + await runtime.shutdown() + #expect(try index.owns(threadID: "native-history")) + #expect(try await runtime.workResources().count == 1) + _ = try await runtime.release(sessionID: id) + #expect(try await runtime.workResources().isEmpty) + #expect(try index.owns(threadID: "native-history")) + } + + @Test + func standaloneWorkCannotBeReportedAsAnEmptySnapshot() async throws { + let runtime = LiveCodexExecRuntime( + configuration: configuration(), workspaceURL: URL(fileURLWithPath: "/tmp"), + client: FakeCodexExecClientAdapter(handles: [ControllableCodexExecHandle()])) + #expect(try await runtime.workResources().isEmpty) + let started = try await runtime.start(prompt: "fixture") + let id = try requiredString("session_id", in: started) + await #expect(throws: (any Error).self) { try await runtime.workResources() } + await runtime.shutdown() + await #expect(throws: (any Error).self) { try await runtime.workResources() } + _ = try await runtime.release(sessionID: id) + #expect(try await runtime.workResources().isEmpty) + } + private func configuration( sandbox: CodexSandboxMode? = nil, approval: CodexApprovalPolicy? = nil, @@ -404,6 +680,43 @@ final class CodexExecRuntimeTests { } } +private actor PendingExecClient: CodexExecClientAdapter { + private let events: AsyncStream.Continuation + private let failure: CodexExecRuntimeError? + private var waiting: CheckedContinuation? + + init(events: AsyncStream.Continuation, failure: CodexExecRuntimeError? = nil) { + self.events = events + self.failure = failure + } + + func run(_ request: CodexExecRunRequest) async throws -> any CodexExecProcessHandleAdapter { + try await launch() + } + + func resume(_ request: CodexExecResumeRequest) async throws -> any CodexExecProcessHandleAdapter { + try await launch() + } + + private func launch() async throws -> any CodexExecProcessHandleAdapter { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + waiting = continuation + events.yield("launch-entered") + } + } onCancel: { + events.yield("launch-cancelled") + } + if let failure { throw failure } + return ControllableCodexExecHandle() + } + + func release() { + waiting?.resume() + waiting = nil + } +} + private actor FakeCodexExecClientAdapter: CodexExecClientAdapter { private var handles: [any CodexExecProcessHandleAdapter] private(set) var runRequests: [CodexExecRunRequest] = [] diff --git a/Tests/CodexAdapterTests/CodexExecutableResolutionTests.swift b/Tests/CodexAdapterTests/CodexExecutableResolutionTests.swift index 9d22918..824c364 100644 --- a/Tests/CodexAdapterTests/CodexExecutableResolutionTests.swift +++ b/Tests/CodexAdapterTests/CodexExecutableResolutionTests.swift @@ -3,18 +3,21 @@ import Testing @testable import CodexAdapter +#if os(Windows) + import WinSDK +#endif + struct CodexExecutableResolutionTests { - @Test(arguments: ["absolute", "relative", "path", "relative-path", "empty-path-entry"]) + @Test(arguments: ["absolute", "relative", "path", "relative-path"]) func resolvesTheConfiguredNameInLaunchContext(form: String) throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } - let executable = root.appendingPathComponent("custom-codex") - try Data("#!/bin/sh\nexit 0\n".utf8).write(to: executable) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + let executable = try installExecutable(in: root, name: "custom-codex") let name = - form == "absolute" ? executable.path : form == "relative" ? "./custom-codex" : "custom-codex" - let path = form == "relative-path" ? "." : form == "empty-path-entry" ? "" : root.path + form == "absolute" + ? executable.path : form == "relative" ? "./" + executable.lastPathComponent : "custom-codex" + let path = form == "relative-path" ? "." : root.path let result = try CodexConfig(executable: name).resolvedExecutableURL( workspaceURL: root, environment: ["PATH": path]) #expect(result == executable.standardizedFileURL) @@ -26,10 +29,8 @@ struct CodexExecutableResolutionTests { let bin = root.appendingPathComponent("bin") try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } - for file in [bin.appendingPathComponent("codex"), root.appendingPathComponent("custom-codex")] { - try Data("#!/bin/sh\nexit 0\n".utf8).write(to: file) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: file.path) - } + _ = try installExecutable(in: bin, name: "codex") + _ = try installExecutable(in: root, name: "custom-codex") for environment in [["PATH": bin.path], [:]] { #expect(throws: ConfigurationError.self) { try CodexConfig(executable: "custom-codex").resolvedExecutableURL( @@ -41,4 +42,81 @@ struct CodexExecutableResolutionTests { workspaceURL: root, environment: [:]) } } + + @Test + func emptyPathEntryFollowsTheNativeDiscoveryContract() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let executable = try installExecutable(in: root, name: "custom-codex") + let configuration = CodexConfig(executable: "custom-codex") + #if os(Windows) + #expect(FileManager.default.fileExists(atPath: executable.path)) + #expect(throws: ConfigurationError.self) { + try configuration.resolvedExecutableURL(workspaceURL: root, environment: ["Path": ""]) + } + #else + let resolved = try configuration.resolvedExecutableURL( + workspaceURL: root, environment: ["PATH": ""]) + #expect(resolved == executable.standardizedFileURL) + #endif + } + + #if os(Windows) + @Test + func nativePathSpellingAndQuotedEntriesUseTheLaunchWorkspace() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let bin = root.appendingPathComponent("tools 雪 with space") + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let executable = try installExecutable(in: bin, name: "custom-codex") + let byPath = try CodexConfig(executable: "custom-codex").resolvedExecutableURL( + workspaceURL: root, environment: ["pAtH": "missing;\"tools 雪 with space\";;"]) + let byRelative = try CodexConfig(executable: "tools 雪 with space\\custom-codex.exe") + .resolvedExecutableURL(workspaceURL: root, environment: [:]) + #expect(byPath == executable.standardizedFileURL) + #expect(byRelative == executable.standardizedFileURL) + } + + @Test + func ambiguousEnvironmentAndDriveRelativeNamesFailWithoutShellDiscovery() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + _ = try installExecutable(in: root, name: "custom-codex") + #expect(throws: ConfigurationError.self) { + try CodexConfig(executable: "custom-codex").resolvedExecutableURL( + workspaceURL: root, environment: ["PATH": root.path, "Path": root.path]) + } + #expect(throws: ConfigurationError.self) { + try CodexConfig(executable: "C:custom-codex.exe").resolvedExecutableURL( + workspaceURL: root, environment: [:]) + } + try Data("@exit /b 0\r\n".utf8).write(to: root.appendingPathComponent("script-only.cmd")) + #expect(throws: ConfigurationError.self) { + try CodexConfig(executable: "script-only").resolvedExecutableURL( + workspaceURL: root, environment: ["Path": root.path, "PATHEXT": ".CMD"]) + } + } + #endif + + private func installExecutable(in directory: URL, name: String) throws -> URL { + #if os(Windows) + var system = [WCHAR](repeating: 0, count: 32_768) + let length = GetSystemDirectoryW(&system, UINT(system.count)) + try #require(length > 0 && length < system.count) + let source = URL( + fileURLWithPath: String(decoding: system.prefix(Int(length)), as: UTF16.self) + ) + .appendingPathComponent("cmd.exe") + let executable = directory.appendingPathComponent(name + ".exe") + try FileManager.default.copyItem(at: source, to: executable) + #else + let executable = directory.appendingPathComponent(name) + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: executable) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], ofItemAtPath: executable.path) + #endif + return executable + } } diff --git a/Tests/CodexAdapterTests/CodexExecutionProviderTests.swift b/Tests/CodexAdapterTests/CodexExecutionProviderTests.swift index 813ccd0..f0e46f2 100644 --- a/Tests/CodexAdapterTests/CodexExecutionProviderTests.swift +++ b/Tests/CodexAdapterTests/CodexExecutionProviderTests.swift @@ -22,11 +22,25 @@ struct CodexExecutionProviderTests { #expect( Set(catalog.tools.map(\.name)) == Set(cases.map(\.name) + ProtocolTools.definitions.map(\.name))) + for (name, risk) in [ + ("start", "full-shell"), ("resume", "full-shell"), ("cancel", "destructive"), + ("list", "read-only"), ("events", "read-only"), ("result", "read-only"), + ("release", "destructive"), + ] { + let tool = try #require(catalog.tools.first { $0.name == "codex.exec.\(name)" }) + #expect(tool._meta?["io.github.computer-mcp/risk"] == .string(risk)) + #expect(tool.annotations.readOnlyHint == (risk == "read-only")) + } + let origin = UUID() for item in cases { + let meta: MCP.Metadata? = + item.name == "codex.exec.start" || item.name == "codex.exec.resume" + ? .init(additionalFields: [CodexWorkInvocation.metadataKey: .string(origin.uuidString)]) + : nil let request = try await client.send( MCP.CallTool.request( .init( - name: item.name, arguments: item.arguments))) + name: item.name, arguments: item.arguments, meta: meta))) let result = try await request.value #expect(result.isError == false) #expect( @@ -42,6 +56,22 @@ struct CodexExecutionProviderTests { try JSONDecoder().decode(MCP.Value.self, from: Data(text.utf8)) == result.structuredContent?.objectValue?["result"]) } + #expect(await exec.invocations == [origin, origin, nil, nil, nil, nil, nil]) + for value: MCP.Value in [ + .null, .bool(true), .int(1), .array([]), .object([:]), .string(""), + .string("not-a-uuid"), .string(" " + origin.uuidString), + .string("{" + origin.uuidString + "}"), + ] { + await #expect(throws: MCPError.self) { + let response = try await client.send( + MCP.CallTool.request( + .init( + name: "codex.exec.start", arguments: ["prompt": .string("never invoked")], + meta: .init(additionalFields: [CodexWorkInvocation.metadataKey: value])))) + return try await response.value + } + } + #expect(await exec.operations.count == 7) let invalid = try await client.callTool(name: "codex.exec.start", arguments: [:]) #expect(invalid.isError == true) await #expect(throws: MCPError.self) { try await client.callTool(name: "codex.unknown") } @@ -53,7 +83,7 @@ struct CodexExecutionProviderTests { _ = try? await serving.value throw error } - #expect(await exec.operations.count == 6) + #expect(await exec.operations.count == 7) #expect(await exec.shutdowns == 1) } @@ -104,15 +134,18 @@ struct CodexExecutionProviderTests { ]), .init("exec.result", ["session_id": .string("session")]), .init("exec.cancel", ["session_id": .string("session")], write: true), + .init("exec.release", ["session_id": .string("session")], write: true), ] } } private actor ExecutionSpy: CodexExecRuntimeProtocol { var operations: [String] = [] + var invocations: [UUID?] = [] var shutdowns = 0 func record(_ operation: String, _ arguments: [String: JSONValue] = [:]) -> JSONValue { operations.append(operation) + invocations.append(CodexWorkInvocation.current) return .object(["operation": .string(operation), "arguments": .object(arguments)]) } func start(prompt: String, model: String?, options: JSONValue?) -> JSONValue { @@ -144,5 +177,9 @@ private actor ExecutionSpy: CodexExecRuntimeProtocol { func cancel(sessionID: String) -> JSONValue { record("exec.cancel", ["session_id": .string(sessionID)]) } + func release(sessionID: String) -> JSONValue { + record("exec.release", ["session_id": .string(sessionID)]) + } + func workResources() -> [CodexWorkResource] { [] } func shutdown() { shutdowns += 1 } } diff --git a/Tests/CodexAdapterTests/CodexHostMCPClientTests.swift b/Tests/CodexAdapterTests/CodexHostMCPClientTests.swift index 45fb662..968a6c9 100644 --- a/Tests/CodexAdapterTests/CodexHostMCPClientTests.swift +++ b/Tests/CodexAdapterTests/CodexHostMCPClientTests.swift @@ -203,8 +203,13 @@ struct CodexHostMCPClientTests { } } let environment = CodexProcessEnvironment.resolved( - base: ["COMPUTER_MCP_HOST_FD": "3"], systemProxy: .init()) + base: [ + "COMPUTER_MCP_HOST_FD": "3", "COMPUTER_MCP_HOST_READ_HANDLE": "144", + "COMPUTER_MCP_HOST_WRITE_HANDLE": "148", + ], systemProxy: .init()) #expect(environment["COMPUTER_MCP_HOST_FD"] == nil) + #expect(environment["COMPUTER_MCP_HOST_READ_HANDLE"] == nil) + #expect(environment["COMPUTER_MCP_HOST_WRITE_HANDLE"] == nil) } } diff --git a/Tests/CodexAdapterTests/CodexLaunchContextTests.swift b/Tests/CodexAdapterTests/CodexLaunchContextTests.swift index d21f746..d01bb9f 100644 --- a/Tests/CodexAdapterTests/CodexLaunchContextTests.swift +++ b/Tests/CodexAdapterTests/CodexLaunchContextTests.swift @@ -29,7 +29,7 @@ struct CodexLaunchContextTests { FileManager.default.fileExists( atPath: root.appendingPathComponent("adapter-state/codex.sqlite").path)) let execution = try context.executionProvider(configuration: configuration) - #expect(execution.tools.count == 6) + #expect(execution.tools.count == 7) await execution.shutdown() await provider.shutdown() } @@ -73,7 +73,7 @@ struct CodexLaunchContextTests { #expect(context.workspaceURL.path == "/tmp/bound-workspace") let provider = try context.executionProvider( configuration: .init(enabled: true, appServerEnabled: false)) - #expect(provider.tools.count == 6) + #expect(provider.tools.count == 7) } @Test func execAndDisabledDefaultDoNotLaunchCodex() async throws { @@ -84,7 +84,7 @@ struct CodexLaunchContextTests { let exec = try context.executionProvider( configuration: .init( enabled: true, executable: "/missing/codex", appServerEnabled: false)) - #expect(exec.tools.count == 6) + #expect(exec.tools.count == 7) _ = try await exec.call(name: "codex.exec.list", arguments: nil) await exec.shutdown() } diff --git a/Tests/CodexAdapterTests/CodexNativeProjectionTests.swift b/Tests/CodexAdapterTests/CodexNativeProjectionTests.swift new file mode 100644 index 0000000..9ac0867 --- /dev/null +++ b/Tests/CodexAdapterTests/CodexNativeProjectionTests.swift @@ -0,0 +1,122 @@ +import Foundation +import MCP +import Testing + +@testable import CodexAdapter + +struct CodexNativeProjectionTests { + @Test(arguments: [ + "thread/start", "thread/resume", "thread/fork", "turn/start", "turn/steer", "review/start", + "thread/compact/start", "thread/archive", "thread/delete", "turn/interrupt", + "thread/queue/add", "thread/queue/update", "thread/queue/start", "thread/realtime/start", + "thread/realtime/appendAudio", "thread/realtime/appendSpeech", "thread/realtime/appendText", + "thread/realtime/stop", "thread/settings/update", "turn/settings/update", + "thread/approveGuardianDeniedAction", "config/mcpServer/reload", "thread/goal/set", + "thread/inject_items", "thread/decrement_elicitation", + ]) + func nativeExecutionAndContinuationCannotAdvertiseRestrictedRisk(method: String) throws { + let descriptor = try #require(CodexAppServerMethodCatalog.method(named: method)) + #expect(descriptor.risk == .fullShell) + #expect( + descriptor.tool._meta?["io.github.computer-mcp/risk"] + == .string("full-shell")) + } + + @Test + func narrowNativeEffectsKeepTheirOwnClassification() throws { + for (method, risk) in [ + ("fs/readFile", CodexOperationRisk.readOnly), ("thread/read", .readOnly), + ("thread/name/set", .workspaceWrite), ("thread/goal/clear", .workspaceWrite), + ("fs/writeFile", .externalWrite), ("fs/remove", .destructive), + ("command/exec/terminate", .destructive), ("process/kill", .destructive), + ("command/exec/resize", .workspaceWrite), ("process/resizePty", .workspaceWrite), + ] { + #expect(try #require(CodexAppServerMethodCatalog.method(named: method)).risk == risk) + } + } + + @Test func everyAdoptedStableRequestHasOneTypedProjection() throws { + let inventory = try ProtocolInventory.bundled() + let methods = try CodexAppServerMethodCatalog.derive(inventory: inventory) + for channel in ProtocolInventory.Channel.allCases { + let actual = methods.filter { $0.channel == channel } + #expect(Set(actual.map(\.method)) == Set(inventory.adoption.adopted[channel.rawValue] ?? [])) + #expect(actual.allSatisfy { $0.risk == CodexAppServerMethodCatalog.risk(for: $0.method) }) + } + #expect(methods.filter { $0.channel == .stable }.count == 96) + #expect(methods.filter { $0.channel == .experimental }.count == 51) + #expect(Set(methods.map(\.toolName)).count == methods.count) + for exclusion in inventory.adoption.excluded { + #expect(!methods.contains { $0.method == exclusion.method }) + } + } + + @Test func schemasPreserveNativeFieldsAndReferences() throws { + let method = try #require(CodexAppServerMethodCatalog.method(named: "turn/start")) + let schema = try #require(method.tool.inputSchema.objectValue) + let params = try #require(schema["properties"]?.objectValue?["params"]?.objectValue) + #expect(params["$ref"] == .string("#/definitions/TurnStartParams")) + let fields = schema["definitions"]?.objectValue?["TurnStartParams"]?.objectValue?["properties"]? + .objectValue + #expect(fields?["outputSchema"] != nil) + #expect(fields?["approvalPolicy"] != nil) + #expect(fields?["toolOutput"] != nil) + #expect(schema["required"] == .array([.string("params")])) + #expect(method.threadParameters["threadId"] == true) + #expect( + CodexAppServerMethodCatalog.method(named: "app/read")?.threadParameters["threadId"] == false) + #expect( + CodexAppServerMethodCatalog.method(named: "thread/section/move")?.threadParameters[ + "beforeThreadId"] == false) + } + + @Test func nativeTypesRejectMalformedArgumentsAndPreserveExtensions() throws { + let exec = try #require(CodexAppServerMethodCatalog.method(named: "command/exec")) + #expect(exec.risk == .fullShell) + #expect(exec.tool.annotations.readOnlyHint == false) + #expect(exec.tool.annotations.destructiveHint == true) + try exec.validate( + params: .object([ + "command": .array([.string("echo"), .string("hello")]), + "extension": .object(["integer": .integer(9_007_199_254_740_993)]), + ])) + for invalid in [JSONValue.object([:]), .object(["command": .string("echo hello")])] { + #expect(throws: CodexToolError.self) { try exec.validate(params: invalid) } + } + let write = try #require(CodexAppServerMethodCatalog.method(named: "fs/writeFile")) + #expect(write.risk == .externalWrite) + #expect(throws: CodexToolError.self) { + try write.validate(params: .object(["path": .integer(12), "dataBase64": .string("")])) + } + try write.validate(params: .object(["path": .string("/tmp/file"), "dataBase64": .string("")])) + } + + @Test func sdkAdoptionCannotAddAnUnclassifiedMethod() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let request = Data( + #"{"oneOf":[{"properties":{"method":{"enum":["future/new"]}},"required":["method"]}]}"#.utf8) + let adoption = Data( + #"{"schema":"swift-codex.codex-app-server-method-adoption.v1","upstreamTag":"rust-v1.2.3","adopted":{"stable":["future/new"],"experimental":[]},"excluded":[]}"# + .utf8) + var files: [String: Any] = [:] + for channel in ProtocolInventory.Channel.allCases { + try FileManager.default.createDirectory( + at: directory.appendingPathComponent(channel.rawValue), withIntermediateDirectories: true) + for direction in ProtocolInventory.Direction.allCases { + let name = "\(channel.rawValue)/\(direction.rawValue).json" + try request.write(to: directory.appendingPathComponent(name)) + files[name] = ["sha256": AppServerSchema.digest(request), "messages": 1] + } + } + try adoption.write(to: directory.appendingPathComponent("adoption.json")) + try JSONSerialization.data(withJSONObject: [ + "codexVersion": "1.2.3", "adoptionSHA256": AppServerSchema.digest(adoption), "files": files, + ]).write(to: directory.appendingPathComponent("receipt.json")) + let inventory = try ProtocolInventory(directory: directory) + #expect(throws: SchemaError.self) { + try CodexAppServerMethodCatalog.derive(inventory: inventory) + } + } +} diff --git a/Tests/CodexAdapterTests/CodexNativeResourcesTests.swift b/Tests/CodexAdapterTests/CodexNativeResourcesTests.swift new file mode 100644 index 0000000..ef861ea --- /dev/null +++ b/Tests/CodexAdapterTests/CodexNativeResourcesTests.swift @@ -0,0 +1,174 @@ +import Foundation +import Testing + +@testable import CodexAdapter + +struct CodexNativeResourcesTests { + @Test func interactiveHandlesRequireTheirOwningConnection() throws { + var resources = CodexNativeResources() + let params = JSONValue.object(["processId": .string("command-1")]) + #expect(throws: CodexToolError.self) { + try resources.prepare(method: "command/exec/write", params: params, generation: 1) + } + let command = try resources.prepare(method: "command/exec", params: params, generation: 1) + _ = try resources.prepare(method: "command/exec/write", params: params, generation: 1) + #expect(throws: CodexToolError.self) { + try resources.prepare(method: "command/exec", params: params, generation: 1) + } + #expect(throws: CodexToolError.self) { + try resources.prepare(method: "command/exec/terminate", params: params, generation: 2) + } + #expect(resources.count == 1) + resources.completed(command) + #expect(resources.count == 0) + } + + @Test func failedStopPreservesOwnershipAndLateCompletionCannotDeleteReplacement() throws { + var resources = CodexNativeResources() + let params = JSONValue.object(["watchId": .string("watch-1")]) + let first = try resources.prepare(method: "fs/watch", params: params, generation: 1) + resources.completed(first) + #expect(resources.count == 1) + let stop = try resources.prepare(method: "fs/unwatch", params: params, generation: 1) + resources.completed(stop, rejected: true) + #expect(resources.count == 1) + resources.completed(stop) + let replacement = try resources.prepare(method: "fs/watch", params: params, generation: 1) + resources.completed(stop) + resources.completed(first, rejected: true) + #expect(resources.count == 1) + resources.completed(replacement, rejected: true) + #expect(resources.count == 0) + } + + @Test func exitNotificationIsGenerationBoundAndKillDoesNotClaimCleanup() throws { + var resources = CodexNativeResources() + let params = JSONValue.object(["processHandle": .string("process-1")]) + let start = try resources.prepare(method: "process/spawn", params: params, generation: 2) + resources.completed(start) + resources.completed( + try resources.prepare(method: "process/kill", params: params, generation: 2)) + #expect(resources.count == 1) + resources.processExited(handle: "process-1", generation: 1) + #expect(resources.count == 1) + resources.processExited(handle: "process-1", generation: 2) + #expect(resources.count == 0) + } + + @Test(arguments: [ + ("command/exec", "command/exec/terminate", "processId", "command"), + ("process/spawn", "process/kill", "processHandle", "process"), + ("fs/watch", "fs/unwatch", "watchId", "watch"), + ("mcpServer/event/stream/start", "mcpServer/event/stream/stop", "subscriptionId", "stream"), + ]) + func workOriginSurvivesContinuationAndUncertainStop( + start: String, stop: String, field: String, kind: String + ) throws { + var resources = CodexNativeResources() + let origin = UUID() + let params = JSONValue.object([field: .string("native-handle")]) + let created = try #require( + try CodexWorkInvocation.$current.withValue(origin) { + try resources.prepare(method: start, params: params, generation: 2) + }) + let owner = try CodexWorkResource( + kind: "codex.app." + kind, id: created.token.uuidString.lowercased(), acquiredBy: origin, + handles: ["native_id": .string("native-handle")]) + #expect(try resources.workResources() == [owner]) + let stopping = try CodexWorkInvocation.$current.withValue(UUID()) { + try resources.prepare(method: stop, params: params, generation: 2) + } + resources.uncertain(stopping) + let unknown = try CodexWorkResource( + kind: owner.kind, id: owner.id, acquiredBy: origin, state: .uncertain, handles: owner.handles) + #expect(try resources.workResources() == [unknown]) + resources.completed(stopping, rejected: true) + #expect(try resources.workResources() == [unknown]) + resources.retired(generation: 1) + #expect(try resources.workResources() == [unknown]) + resources.retired(generation: 2) + #expect(try resources.workResources().isEmpty) + } + + @Test + func reusedNativeHandleGetsANewOwnerAndRejectsLateMutations() throws { + var resources = CodexNativeResources() + let params = JSONValue.object(["watchId": .string("reused")]) + let first = try CodexWorkInvocation.$current.withValue(UUID()) { + try resources.prepare(method: "fs/watch", params: params, generation: 1) + } + let firstOwner = try #require(resources.workResources().first) + let stopping = try resources.prepare(method: "fs/unwatch", params: params, generation: 1) + resources.completed(stopping) + let origin = UUID() + let second = try CodexWorkInvocation.$current.withValue(origin) { + try resources.prepare(method: "fs/watch", params: params, generation: 1) + } + let secondOwner = try #require(resources.workResources().first) + #expect(secondOwner.acquiredBy == origin) + #expect(secondOwner.id != firstOwner.id) + #expect(firstOwner.handles == ["native_id": .string("reused")]) + #expect(secondOwner.handles == firstOwner.handles) + resources.uncertain(first) + resources.uncertain(stopping) + resources.completed(first, rejected: true) + resources.completed(stopping) + #expect(try resources.workResources() == [secondOwner]) + resources.uncertain(second) + #expect(try resources.workResources().first?.state == .uncertain) + resources.completed(second, rejected: true) + #expect(try resources.workResources().isEmpty) + } + + @Test + func onlyNativeCompletionOrOwningGenerationCleanupReleasesWork() throws { + var resources = CodexNativeResources() + let origin = UUID() + let process = JSONValue.object(["processHandle": .string("owned")]) + let ticket = try CodexWorkInvocation.$current.withValue(origin) { + try resources.prepare(method: "process/spawn", params: process, generation: 1) + } + resources.uncertain(ticket) + resources.completed( + try resources.prepare(method: "process/kill", params: process, generation: 1)) + #expect(try resources.workResources().first?.state == .uncertain) + resources.processExited(handle: "owned", generation: 2) + #expect(try resources.workResources().count == 1) + resources.processExited(handle: "owned", generation: 1) + #expect(try resources.workResources().isEmpty) + let command = try CodexWorkInvocation.$current.withValue(origin) { + try resources.prepare( + method: "command/exec", params: .object(["processId": .string("owned")]), generation: 2) + } + resources.uncertain(command) + resources.completed(command) + #expect(try resources.workResources().isEmpty) + } + + @Test + func unboundLiveHandlesMakeObservationUnavailable() throws { + var resources = CodexNativeResources() + #expect(try resources.workResources().isEmpty) + let ticket = try resources.prepare( + method: "fs/watch", params: .object(["watchId": .string("standalone")]), generation: 1) + #expect(throws: (any Error).self) { try resources.workResources() } + resources.completed(ticket, rejected: true) + #expect(try resources.workResources().isEmpty) + } + + @Test func outstandingOwnershipIsBoundedAndUncertainStartsRemainReserved() throws { + var resources = CodexNativeResources() + for index in 0..<256 { + _ = try resources.prepare( + method: "fs/watch", params: .object(["watchId": .string("\(index)")]), generation: 1) + } + #expect(throws: CodexToolError.self) { + try resources.prepare( + method: "fs/watch", params: .object(["watchId": .string("next")]), generation: 1) + } + resources.retired(generation: 2) + #expect(resources.count == 256) + resources.retired(generation: 1) + #expect(resources.count == 0) + } +} diff --git a/Tests/CodexAdapterTests/CodexOperationalDiagnosticsTests.swift b/Tests/CodexAdapterTests/CodexOperationalDiagnosticsTests.swift index 2f9eff7..95841f2 100644 --- a/Tests/CodexAdapterTests/CodexOperationalDiagnosticsTests.swift +++ b/Tests/CodexAdapterTests/CodexOperationalDiagnosticsTests.swift @@ -220,6 +220,7 @@ final class CodexOperationalDiagnosticsTests { "profile_id": .string("profile-1"), "workspace_id": .string("workspace-1"), "capability_id": .string("git.commit"), + "output_byte_count": .integer(9_007_199_254_740_993), "decision": .string("allowed"), "input_digest": .string("sha256:input"), "output_digest": .string("sha256:output"), @@ -268,6 +269,9 @@ final class CodexOperationalDiagnosticsTests { #expect(findingCodes.contains("thread_ownership_requires_reconciliation")) #expect(object["thread_ownership_receipts"]?.arrayValue?.count == 1) #expect(audits.count == 2) + #expect( + audits.first { $0.objectValue?["category"] == .string("git") }? + .objectValue?["output_byte_count"] == .integer(9_007_199_254_740_993)) let gitAudit = try #require( audits.first { $0.objectValue?["category"] == .string("git") } ) diff --git a/Tests/CodexAdapterTests/CodexProcessEnvironmentTests.swift b/Tests/CodexAdapterTests/CodexProcessEnvironmentTests.swift index 52851e1..4ff5e75 100644 --- a/Tests/CodexAdapterTests/CodexProcessEnvironmentTests.swift +++ b/Tests/CodexAdapterTests/CodexProcessEnvironmentTests.swift @@ -47,13 +47,24 @@ final class CodexProcessEnvironmentTests { #expect(environment["PATH"] == "/usr/bin") #expect(environment["HTTP_PROXY"] == "http://127.0.0.1:6152") - #expect(environment["http_proxy"] == "http://127.0.0.1:6152") + #if !os(Windows) + #expect(environment["http_proxy"] == "http://127.0.0.1:6152") + #endif #expect(environment["HTTPS_PROXY"] == "http://127.0.0.1:6152") - #expect(environment["https_proxy"] == "http://127.0.0.1:6152") + #if !os(Windows) + #expect(environment["https_proxy"] == "http://127.0.0.1:6152") + #endif #expect(environment["ALL_PROXY"] == "socks5://127.0.0.1:6153") - #expect(environment["all_proxy"] == "socks5://127.0.0.1:6153") + #if !os(Windows) + #expect(environment["all_proxy"] == "socks5://127.0.0.1:6153") + #endif #expect(environment["NO_PROXY"] == "localhost,127.0.0.1,::1,*.local") - #expect(environment["no_proxy"] == "localhost,127.0.0.1,::1,*.local") + #if !os(Windows) + #expect(environment["no_proxy"] == "localhost,127.0.0.1,::1,*.local") + #else + #expect( + environment.keys.sorted() == ["ALL_PROXY", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "PATH"]) + #endif } @Test @@ -73,14 +84,42 @@ final class CodexProcessEnvironmentTests { #expect(environment["HTTP_PROXY"] == nil) #expect(environment["http_proxy"] == nil) - #expect(environment["HTTPS_PROXY"] == "http://inherited.example:8080") + #if os(Windows) + #expect(environment["HTTPS_PROXY"] == nil) + #else + #expect(environment["HTTPS_PROXY"] == "http://inherited.example:8080") + #endif #expect(environment["https_proxy"] == "http://inherited.example:8080") #expect(environment["ALL_PROXY"] == nil) #expect(environment["all_proxy"] == nil) #expect(environment["NO_PROXY"] == "internal.example") - #expect(environment["no_proxy"] == "internal.example") + #if os(Windows) + #expect(environment["no_proxy"] == nil) + #else + #expect(environment["no_proxy"] == "internal.example") + #endif } + #if os(Windows) + @Test + func testNativeCaseAliasesDoNotLeakParentAuthorityOrDuplicateProxies() { + let base = [ + "Computer_Mcp_Host_Context": "parent-context", "Computer_Mcp_Host_Fd": "123", + "Codex_Thread_Id": "parent-thread", "Codex_Permission_Profile": "parent-profile", + "Codex_Home": "C:\\Codex", "hTtPs_PrOxY": "http://proxy.example:8080", + "No_PrOxY": "internal.example", "CODEX_THREAD_ID\0suffix": "invalid-key", + ] + let environment = CodexProcessEnvironment.resolved( + base: base, + systemProxy: SystemNetworkProxySettings(httpsProxy: "http://system.example:9000")) + #expect( + environment == [ + "Codex_Home": "C:\\Codex", "hTtPs_PrOxY": "http://proxy.example:8080", + "No_PrOxY": "internal.example", "CODEX_THREAD_ID\0suffix": "invalid-key", + ]) + } + #endif + @Test func testNoProxyConfigurationLeavesTheBaseEnvironmentUnchanged() { let base = ["PATH": "/usr/bin", "LANG": "en_US.UTF-8"] diff --git a/Tests/CodexAdapterTests/CodexRecentThreadReaderTests.swift b/Tests/CodexAdapterTests/CodexRecentThreadReaderTests.swift index 4486e07..7a30029 100644 --- a/Tests/CodexAdapterTests/CodexRecentThreadReaderTests.swift +++ b/Tests/CodexAdapterTests/CodexRecentThreadReaderTests.swift @@ -5,6 +5,35 @@ import Testing @Suite(.serialized) final class CodexRecentThreadReaderTests { + @Test + func cursorRetainsItsSnapshotAcrossAppendAndReaderReconnect() throws { + let fixture = try RecentThreadFixture(recordCount: 1_000) + defer { fixture.remove() } + let limits = CodexRecentThreadLimits(maxReadBytes: 16_384) + let first = try fixture.reader.read( + threadID: fixture.threadID, beforeCursor: nil, limits: limits) + let cursor = try #require(first.objectValue?["next_before_cursor"]?.stringValue) + let before = try fixture.reader.read( + threadID: fixture.threadID, beforeCursor: cursor, limits: limits) + let writer = try FileHandle(forWritingTo: fixture.rollout) + try writer.seekToEnd() + try writer.write( + contentsOf: Data( + "{\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"message\":\"appended\"}}\n" + .utf8)) + try writer.close() + let reconnected = CodexRecentThreadReader( + metadata: fixture.metadata, allowedRolloutRoot: fixture.root) + let after = try reconnected.read( + threadID: fixture.threadID, beforeCursor: cursor, limits: limits) + for field in ["recent_turns", "recent_items", "recent_messages", "next_before_cursor", "goal"] { + #expect(before.objectValue?[field] == after.objectValue?[field]) + } + #expect( + before.objectValue?["bounds"]?.objectValue?["page_bytes_read"] + == after.objectValue?["bounds"]?.objectValue?["page_bytes_read"]) + } + @Test func testLargePersistedThreadReadIsBoundedAndPaginatesWithoutOverlap() throws { let fixture = try RecentThreadFixture(recordCount: 30_000) diff --git a/Tests/CodexAdapterTests/CodexWorkContinuationTests.swift b/Tests/CodexAdapterTests/CodexWorkContinuationTests.swift new file mode 100644 index 0000000..f94a83f --- /dev/null +++ b/Tests/CodexAdapterTests/CodexWorkContinuationTests.swift @@ -0,0 +1,101 @@ +import Foundation +import MCP +import Testing + +@testable import CodexAdapter + +struct CodexWorkContinuationTests { + @Test + func firstClassAndGenericNativeCallsLocateTheSameKindsOfWork() throws { + let generic = try selectors("codex.app.methods.call") + for method in CodexAppServerMethodCatalog.methods { + let direct = try selectors(method.toolName) + let dispatched = generic.filter { + $0["when"]?.objectValue?["values"]?.arrayValue?.contains(.string(method.method)) == true + } + #expect(direct.count == dispatched.count, "Missing generic binding for \(method.method)") + for selector in direct { + #expect( + dispatched.contains { + $0["kind"] == selector["kind"] && $0["handles"] == selector["handles"] + }) + } + let tool = try CodexWorkSnapshot.declaring(method.tool) + #expect(tool.inputSchema == method.tool.inputSchema) + #expect(tool._meta?["io.github.computer-mcp/risk"] == .string(method.risk.rawValue)) + } + } + + @Test + func startsDoNotLocateAnOldLifetimeWithTheSameReusableNativeHandle() throws { + for method in [ + "command/exec", "process/spawn", "fs/watch", "mcpServer/event/stream/start", "thread/fork", + ] { + let descriptor = try #require(CodexAppServerMethodCatalog.method(named: method)) + #expect( + try selectors(descriptor.toolName).allSatisfy { + $0["handles"]?.objectValue?["native_id"] == nil + }) + } + for name in [ + "codex.exec.start", "codex.exec.resume", "codex.app.thread.start", "codex.app.thread.fork", + ] { + #expect(try selectors(name).isEmpty) + } + let command = try #require(selectors("codex.app.native.command.exec.write").first) + #expect(command["kind"] == .string("codex.app.command")) + #expect(command["handles"] == .object(["native_id": .string("/params/processId")])) + let process = try #require(selectors("codex.app.native.process.writeStdin").first) + #expect(process["kind"] == .string("codex.app.process")) + #expect(process["handles"] == .object(["native_id": .string("/params/processHandle")])) + } + + @Test + func optionalNativeThreadScopeAllowsNullWithoutChangingNativeValidation() throws { + let descriptor = try #require(CodexAppServerMethodCatalog.method(named: "app/read")) + #expect(descriptor.threadParameters["threadId"] == false) + let bindings = try selectors(descriptor.toolName) + #expect(!bindings.isEmpty) + #expect( + bindings.allSatisfy { + $0["nullable_handles"] == .array([.string("thread_id")]) + && $0["handles"] == .object(["thread_id": .string("/params/threadId")]) + }) + let required = try selectors("codex.app.native.turn.start") + #expect(required.allSatisfy { $0["nullable_handles"] == nil }) + } + + @Test + func adapterHandlesUseTheirOwnedIdentityAndUnscopedOperationsStayUnscoped() throws { + #expect( + try selectors("codex.exec.result") == [ + ["kind": .string("codex.exec.session"), "handles": .object(["id": .string("/session_id")])] + ]) + #expect( + try selectors("codex.app.approvals.respond") == [ + [ + "kind": .string("codex.app.server-request"), + "handles": .object(["approval_id": .string("/approval_id")]), + ] + ]) + let owners = try selectors("codex.app.runtimes.stop") + #expect(owners.count == 16) + #expect(owners.allSatisfy { $0["handles"] == .object(["runtime_id": .string("/runtime_id")]) }) + for name in [ + "codex.exec.list", "codex.app.status", "codex.app.events.read", "codex.app.requests.list", + "codex.app.runtime.stop", + ] { + #expect(try selectors(name).isEmpty) + } + } + + private func selectors(_ name: String) throws -> [[String: JSONValue]] { + guard let value = try CodexWorkContinuation.declaration(for: name) else { return [] } + let data = try JSONEncoder().encode(value) + #expect(data.count <= 16_384) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + let selectors = try #require(decoded.objectValue?["selectors"]?.arrayValue) + #expect((1...16).contains(selectors.count)) + return try selectors.map { try #require($0.objectValue) } + } +} diff --git a/Tests/CodexAdapterTests/CodexWorkSnapshotTests.swift b/Tests/CodexAdapterTests/CodexWorkSnapshotTests.swift new file mode 100644 index 0000000..754a6da --- /dev/null +++ b/Tests/CodexAdapterTests/CodexWorkSnapshotTests.swift @@ -0,0 +1,221 @@ +import Foundation +import MCP +import Testing + +@testable import CodexAdapter + +struct CodexWorkSnapshotTests { + @Test func aliasesPreserveExactIdentifiersAndAdvanceSnapshotRevision() async throws { + let source = WorkSource() + let snapshot = CodexWorkSnapshot { try await source.collect() } + let original = try resource("lifetime") + await source.set([original]) + let initial = try await payload(snapshot.read()) + let handles: [String: JSONValue] = [ + "native_id": .integer(9_007_199_254_740_993), "thread_id": .string("opaque"), + ] + let enriched = try original.addingHandles(handles) + #expect(enriched.id == original.id && enriched.acquiredBy == original.acquiredBy) + #expect(enriched.json.objectValue?["handles"] == .object(handles)) + await source.set([enriched]) + let changed = try await payload(snapshot.read()) + #expect(initial["revision"] == .integer(0)) + #expect(changed["revision"] == .integer(1)) + #expect(changed["resources"] == .array([enriched.json])) + #expect(try await payload(snapshot.read()) == changed) + #expect(throws: MCPError.self) { + try enriched.addingHandles(["native_id": .string("9007199254740993")]) + } + await snapshot.shutdown() + } + + @Test func invalidAliasesCannotPublishOwnedIdentifiers() throws { + let invalid: [[String: JSONValue]] = [ + ["id": .string("shadow")], ["native": .null], ["native": .bool(true)], + ["native": .number(1.5)], ["native": .string("")], ["native": .string("bad\nvalue")], + ["bad\nname": .string("value")], ["native": .string(String(repeating: "x", count: 1025))], + Dictionary(uniqueKeysWithValues: (0...16).map { ("alias\($0)", .string("value")) }), + ] + for handles in invalid { + #expect(throws: MCPError.self) { + try CodexWorkResource(kind: "work", id: "lifetime", acquiredBy: UUID(), handles: handles) + } + } + } + + @Test func revisionsChangeOnlyForCompleteChangedObservations() async throws { + let source = WorkSource() + let snapshot = CodexWorkSnapshot { try await source.collect() } + let initial = try await payload(snapshot.read()) + #expect(initial["revision"] == .integer(0)) + #expect(initial["resources"] == .array([])) + let first = try resource("z") + let second = try resource("a") + await source.set([first, second]) + let active = try await payload(snapshot.read()) + #expect(active["instance_id"] == initial["instance_id"]) + #expect(active["revision"] == .integer(1)) + #expect(active["resources"] == .array([second.json, first.json])) + await source.set([second, first]) + #expect(try await payload(snapshot.read()) == active) + await source.set([first, first]) + await #expect(throws: MCPError.self) { try await snapshot.read() } + await source.set([first, second]) + #expect(try await payload(snapshot.read()) == active) + await source.set([], fail: true) + await #expect(throws: MCPError.self) { try await snapshot.read() } + await source.set([]) + #expect(try await payload(snapshot.read())["revision"] == .integer(2)) + await snapshot.shutdown() + await #expect(throws: MCPError.self) { try await snapshot.read() } + } + + @Test(arguments: [ + "", "bad\nidentity", "bad\u{7f}identity", "bad\u{9f}identity", "bad\u{200b}identity", + String(repeating: "a", count: 1025), String(repeating: "界", count: 342), + ]) + func invalidIdentifiersFailTheEntireObservation(_ invalid: String) async throws { + for invalidKind in [false, true] { + let row = try CodexWorkResource( + kind: invalidKind ? invalid : "work", id: invalidKind ? "id" : invalid, acquiredBy: UUID()) + let snapshot = CodexWorkSnapshot { [row] } + await #expect(throws: MCPError.self) { try await snapshot.read() } + await snapshot.shutdown() + } + } + + @Test func rowAndByteBoundsNeverTruncateLiveWork() async throws { + let source = WorkSource() + let snapshot = CodexWorkSnapshot { try await source.collect() } + let rows = try (0..<1024).map { try resource(String($0)) } + await source.set(rows) + #expect(try await payload(snapshot.read())["resources"]?.arrayValue?.count == 1024) + await source.set(rows + [try resource("overflow")]) + await #expect(throws: MCPError.self) { try await snapshot.read() } + let oversized = try (0..<600).map { + try resource(String($0) + String(repeating: "x", count: 1000)) + } + await source.set(oversized) + await #expect(throws: MCPError.self) { try await snapshot.read() } + await source.set(rows) + #expect(try await payload(snapshot.read())["revision"] == .integer(0)) + await snapshot.shutdown() + } + + @Test(.timeLimit(.minutes(1))) + func overlappingReadsCannotReuseAnObservationFromBeforeTheirAdmission() async throws { + let gate = WorkReadGate() + let snapshot = CodexWorkSnapshot { await gate.collect() } + let first = Task { try await snapshot.read() } + await gate.waitForRead() + await #expect(throws: MCPError.self) { try await snapshot.read() } + first.cancel() + // Caller cancellation cannot detach the still-running provider observation. + await #expect(throws: MCPError.self) { try await snapshot.read() } + let stopped = Task { await snapshot.shutdown() } + await gate.release() + _ = await first.result + await stopped.value + await #expect(throws: MCPError.self) { try await snapshot.read() } + } + + @Test(.timeLimit(.minutes(1)), arguments: [false, true]) + func standardMCPDiscoveryAndReadExposeCompleteWorkOrAnExplicitFailure( + unavailableProvider: Bool + ) async throws { + let provider: CodexAppServerProvider? = + unavailableProvider + ? .init( + appServer: FakeAppServerRuntime(), owner: nil, database: nil, + workspaceURL: FileManager.default.temporaryDirectory, recentThreadReader: nil, + localControlAllowed: false) : nil + let pair = await InMemoryTransport.createConnectedPair() + try await pair.server.connect() + let serving = Task { + try await CodexAdapterServer.serve(transport: pair.server, appServer: provider) + } + let client = MCP.Client(name: "work-snapshot-tests", version: "1") + do { + let initialized = try await client.connect(transport: pair.client) + #expect(initialized.capabilities.resources?.subscribe == false) + #expect(initialized.capabilities.resources?.listChanged == false) + let catalog = try await client.listTools() + for tool in catalog.tools { + #expect( + tool._meta?[CodexWorkSnapshot.metadataKey] + == .object([ + "format_version": .int(1), "uri": .string(CodexWorkSnapshot.uri), + ])) + } + let listed = try await client.listResources() + #expect(listed.resources.map(\.uri) == [CodexWorkSnapshot.uri]) + await #expect(throws: MCPError.self) { try await client.listResources(cursor: "unknown") } + await #expect(throws: MCPError.self) { try await client.readResource(uri: "unknown") } + if unavailableProvider { + await #expect(throws: MCPError.self) { + try await client.readResource(uri: CodexWorkSnapshot.uri) + } + } else { + let contents = try await client.readResource(uri: CodexWorkSnapshot.uri) + let value = try payload(.init(contents: contents)) + #expect(value["format_version"] == .integer(1)) + #expect(value["resources"] == .array([])) + let instance = try #require(value["instance_id"]?.stringValue) + #expect(UUID(uuidString: instance)?.uuidString.lowercased() == instance) + } + await client.disconnect() + try await serving.value + } catch { + await client.disconnect() + await pair.server.disconnect() + _ = try? await serving.value + throw error + } + } + + private func resource(_ id: String) throws -> CodexWorkResource { + try .init(kind: "work", id: id, acquiredBy: UUID()) + } + + private func payload(_ result: MCP.ReadResource.Result) throws -> [String: JSONValue] { + #expect(result.contents.count == 1) + let content = try #require(result.contents.first) + #expect(content.uri == CodexWorkSnapshot.uri) + #expect(content.mimeType == "application/json") + let text = try #require(content.text) + return try #require(JSONDecoder().decode(JSONValue.self, from: Data(text.utf8)).objectValue) + } +} + +private actor WorkSource { + private var rows: [CodexWorkResource] = [] + private var fail = false + func set(_ rows: [CodexWorkResource], fail: Bool = false) { + self.rows = rows + self.fail = fail + } + func collect() throws -> [CodexWorkResource] { + if fail { throw MCPError.internalError("Native work is unavailable.") } + return rows + } +} + +private actor WorkReadGate { + private var pending: CheckedContinuation<[CodexWorkResource], Never>? + private var started: CheckedContinuation? + func collect() async -> [CodexWorkResource] { + await withCheckedContinuation { + pending = $0 + started?.resume() + started = nil + } + } + func waitForRead() async { + if pending != nil { return } + await withCheckedContinuation { started = $0 } + } + func release() { + pending?.resume(returning: []) + pending = nil + } +} diff --git a/Tests/CodexAdapterTests/JSONIntegerTests.swift b/Tests/CodexAdapterTests/JSONIntegerTests.swift new file mode 100644 index 0000000..9f81c5f --- /dev/null +++ b/Tests/CodexAdapterTests/JSONIntegerTests.swift @@ -0,0 +1,57 @@ +import Foundation +import MCP +import Testing + +@testable import CodexAdapter + +struct JSONIntegerTests { + @Test(arguments: [ + Int64.min, 9_007_199_254_740_991, 9_007_199_254_740_992, + 9_007_199_254_740_993, Int64.max, + ]) + func nativeAndMCPRoundTrip(value: Int64) throws { + let input = Data("{\"value\":\(value)}".utf8) + let json = try JSONDecoder().decode(JSONValue.self, from: input) + #expect(json.objectValue?["value"] == .integer(value)) + #expect(json.objectValue?["value"]?.intValue == Int(value)) + let encoded = try JSONEncoder().encode(json) + let mcp = try JSONDecoder().decode(MCP.Value.self, from: encoded) + #expect(mcp.objectValue?["value"] == .int(Int(value))) + let native = try JSONDecoder().decode(AppServerJSON.self, from: encoded) + #expect(native == .object(["value": .number(.integer(value))])) + #expect(try JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(native)) == json) + } + + @Test(arguments: ["9223372036854775808", "-9223372036854775809", "18446744073709551615"]) + func unsupportedIntegerRangeIsExplicit(text: String) { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(JSONValue.self, from: Data(text.utf8)) + } + } + + @Test(arguments: [Int64.min, 9_007_199_254_740_993, Int64.max]) + func sdkDecimalStoragePreservesIntegralValues(value: Int64) throws { + let native = AppServerJSON.object(["nested": .array([.number(.decimal(Decimal(value)))])]) + #expect(try ProtocolTools.mcpValue(native) == .object(["nested": .array([.int(Int(value))])])) + } + + @Test func sdkDecimalRangeAndFractionAreExplicit() throws { + #expect( + try ProtocolTools.mcpValue(.number(.decimal(Decimal(string: "1.25")!))) == .double(1.25)) + #expect(throws: DecodingError.self) { + try ProtocolTools.mcpValue(.number(.decimal(Decimal(string: "9223372036854775808")!))) + } + #expect(throws: EncodingError.self) { + try JSONEncoder().encode(JSONValue.number(Double(Int64.max))) + } + let encoded = try JSONEncoder().encode(JSONValue.number(Double(Int64.min))) + #expect(String(decoding: encoded, as: UTF8.self) == String(Int64.min)) + } + + @Test func numericIdentityDoesNotRoundIntegers() throws { + #expect(JSONValue.integer(9_007_199_254_740_993) != .number(9_007_199_254_740_992)) + #expect(JSONValue.integer(2) == .number(2)) + #expect(try JSONDecoder().decode(JSONValue.self, from: Data("1.25".utf8)) == .number(1.25)) + #expect(try JSONDecoder().decode(JSONValue.self, from: Data("true".utf8)) == .bool(true)) + } +} diff --git a/Tests/CodexAdapterTests/ProtocolToolsTests.swift b/Tests/CodexAdapterTests/ProtocolToolsTests.swift index 9453036..895f56e 100644 --- a/Tests/CodexAdapterTests/ProtocolToolsTests.swift +++ b/Tests/CodexAdapterTests/ProtocolToolsTests.swift @@ -74,7 +74,7 @@ struct ProtocolToolsTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } - try Data(#"{"codexVersion":"0.154.0","files":{}}"#.utf8).write( + try Data(#"{"codexVersion":"0.154.0","adoptionSHA256":"invalid","files":{}}"#.utf8).write( to: directory.appendingPathComponent("receipt.json")) #expect(throws: SchemaError.self) { try ProtocolInventory(directory: directory) } } @@ -102,8 +102,14 @@ struct ProtocolToolsTests { files[path] = ["sha256": AppServerSchema.digest(bytes), "messages": 1] } } + let adoption = Data( + #"{"schema":"swift-codex.codex-app-server-method-adoption.v1","upstreamTag":"rust-v1.2.3","adopted":{"stable":["test"],"experimental":[]},"excluded":[]}"# + .utf8) + try adoption.write(to: directory.appendingPathComponent("adoption.json")) let receiptURL = directory.appendingPathComponent("receipt.json") - try JSONSerialization.data(withJSONObject: ["codexVersion": "1.2.3", "files": files]).write( + try JSONSerialization.data(withJSONObject: [ + "codexVersion": "1.2.3", "adoptionSHA256": AppServerSchema.digest(adoption), "files": files, + ]).write( to: receiptURL) #expect(try ProtocolInventory(directory: directory).receipt.codexVersion == "1.2.3") let target = "experimental/ServerNotification.json" @@ -111,7 +117,9 @@ struct ProtocolToolsTests { try (bytes + Data([32])).write(to: directory.appendingPathComponent(target)) } else { files[target] = ["sha256": AppServerSchema.digest(bytes), "messages": 2] - try JSONSerialization.data(withJSONObject: ["codexVersion": "1.2.3", "files": files]).write( + try JSONSerialization.data(withJSONObject: [ + "codexVersion": "1.2.3", "adoptionSHA256": AppServerSchema.digest(adoption), "files": files, + ]).write( to: receiptURL) } #expect(throws: SchemaError.self) { try ProtocolInventory(directory: directory) } diff --git a/Tests/WindowsAdapter/ProtocolCheck.py b/Tests/WindowsAdapter/ProtocolCheck.py new file mode 100644 index 0000000..73532b8 --- /dev/null +++ b/Tests/WindowsAdapter/ProtocolCheck.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Exercise a linked adapter and real Codex protocol without model authentication.""" + +import argparse +import ctypes +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import tempfile +import uuid + + +repository = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("workflow_check", repository / "Scripts/check-workflow.py") +workflow = importlib.util.module_from_spec(spec) +spec.loader.exec_module(workflow) +runtime_spec = importlib.util.spec_from_file_location("windows_runtime", repository / "Scripts/windows_runtime.py") +runtime = importlib.util.module_from_spec(runtime_spec) +runtime_spec.loader.exec_module(runtime) + + +class ProcessObservation: + """Retain the native child identity before asking its owner to stop it.""" + + def __init__(self, pid): + self.pid = pid + self.handle = None + if os.name == "nt": + from ctypes import wintypes + self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) + self.kernel.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + self.kernel.OpenProcess.restype = wintypes.HANDLE + self.kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + self.kernel.WaitForSingleObject.restype = wintypes.DWORD + self.kernel.CloseHandle.argtypes = [wintypes.HANDLE] + self.kernel.CloseHandle.restype = wintypes.BOOL + self.handle = self.kernel.OpenProcess(0x00100000, False, pid) # SYNCHRONIZE + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + + def require_exited(self): + if self.handle: + assert self.kernel.WaitForSingleObject(self.handle, 0) == 0, "Owned child has not exited" + else: + try: + os.kill(self.pid, 0) + except ProcessLookupError: + return + raise AssertionError("Owned child has not exited") + + def close(self): + if self.handle: + self.kernel.CloseHandle(self.handle) + self.handle = None + + +def remove_private_state(root): + """Git marks immutable object files read-only on Windows, even in a private home.""" + repaired = 0 + + def remove_read_only(function, path, failure): + nonlocal repaired + error = failure[1] + mode = os.lstat(path).st_mode + if (not isinstance(error, PermissionError) or function is not os.unlink + or not stat.S_ISREG(mode) or mode & stat.S_IWRITE): + raise error + os.chmod(path, mode | stat.S_IWRITE) + function(path) + repaired += 1 + + shutil.rmtree(root, onerror=remove_read_only) + return repaired + + +def run(adapter, codex, evidence_directory=None, app_local_runtime=False): + if app_local_runtime and os.name != "nt": + raise ValueError("App-local runtime acceptance requires native Windows") + if evidence_directory is not None: + evidence_directory.mkdir(parents=True, exist_ok=False) + root = Path(tempfile.mkdtemp(prefix="native-adapter-汉字-")).resolve() + workspace = root / "workspace" + state = root / "codex" + home = root / "home" + for directory in [workspace, state, home, home / "AppData/Roaming", home / "AppData/Local"]: + directory.mkdir(parents=True, mode=0o700, exist_ok=True) + # Only system/toolchain lookup inputs are inherited; credentials and user homes are not. + environment = {"PATH": os.environ.get("PATH", ""), "HOME": str(home), + "USERPROFILE": str(home), "APPDATA": str(home / "AppData/Roaming"), + "LOCALAPPDATA": str(home / "AppData/Local"), "CODEX_HOME": str(state), + "TEMP": str(root), "TMP": str(root)} + for key in ["SystemRoot", "WINDIR", "COMSPEC"]: + if key in os.environ: + environment[key] = os.environ[key] + if app_local_runtime: + system_directory = Path(os.environ["SystemRoot"]) / "System32" + environment["PATH"] = os.pathsep.join([str(system_directory), os.environ["SystemRoot"]]) + (state / "config.toml").write_text('cli_auth_credentials_store = "file"\n', encoding="utf-8") + context = {"formatVersion": 1, "runtimeID": str(uuid.uuid4()), "caller": "local-mcp", + "profileID": "local-admin", "principalID": "native-protocol-check", + "workspace": {"id": "native-protocol-check", "rootPath": str(workspace)}} + environment["COMPUTER_MCP_HOST_CONTEXT"] = json.dumps(context) + config = root / "adapter.json" + config.write_text(json.dumps({"enabled": True, "executable": str(codex), + "app_server_enabled": True, "exec_enabled": True, + "sandbox": "read-only", "approval_policy": "untrusted"}), encoding="utf-8") + arguments = [str(adapter), "--config", str(config), "--state-directory", str(root / "adapter-state")] + receipt = {"evidence_class": "native-standard-mcp-protocol", "real_model_verified": False, + "model_turns_started": 0, "production_host_used": False, "inherited_credentials": False, + "runtime_environment": "selected-toolchain", "clean_machine_relocation_verified": False, + "adapter_sha256": hashlib.sha256(adapter.read_bytes()).hexdigest(), + "codex_sha256": hashlib.sha256(codex.read_bytes()).hexdigest(), "connections": []} + if app_local_runtime: + receipt["runtime_environment"] = "app-local-with-system-only-path" + receipt["child_path"] = environment["PATH"] + receipt["module_observations"] = [] + process = None + observations = [] + confirmed = False + phase = "executable versions" + try: + for executable, key in [(adapter, "adapter_version"), (codex, "codex_version")]: + receipt[key] = subprocess.run([str(executable), "--version"], env=environment, + capture_output=True, text=True, check=True, timeout=10).stdout.strip() + with (root / "adapter-stderr.log").open("w", encoding="utf-8") as stderr: + for attempt in range(2): + process = subprocess.Popen(arguments, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=stderr, text=True, encoding="utf-8", cwd=workspace, env=environment) + client = workflow.MCPClient(process) + phase = f"connection {attempt + 1} initialize" + initialized = client.request("initialize", {"protocolVersion": "2025-11-25", "capabilities": {}, + "clientInfo": {"name": "native-adapter-protocol-check", "version": "1"}}) + client.send({"method": "notifications/initialized", "params": {}}) + client.request("ping", {}) + phase = f"connection {attempt + 1} catalog" + catalog = client.request("tools/list", {})["tools"] + names = {tool["name"] for tool in catalog} + assert len(names) == len(catalog), "Duplicate tool names" + assert {"codex.app.thread.start", "codex.app.runtime.stop", "codex.exec.start", + "codex.exec.cancel", "codex.diagnostics.snapshot"} <= names, names + assert client.work() == [], "Discovery created native work" + diagnostic = client.request("tools/call", {"name": "codex.diagnostics.snapshot", "arguments": {"limit": 10}}) + assert diagnostic.get("isError") is False, diagnostic + result = diagnostic["structuredContent"]["result"] + assert json.loads(diagnostic["content"][0]["text"]) == result, diagnostic + assert result["persistence_available"] is True, result + assert result["host_diagnostics_available"] is False, result + assert client.call("thread.loaded.list")["data"] == [] + phase = f"connection {attempt + 1} native lifecycle" + if app_local_runtime: + receipt["module_observations"].append(runtime.verify_app_local_modules( + runtime.loaded_modules(process.pid), adapter, system_directory)) + started = client.call("thread.start") + thread_id = started["thread"]["id"] + origin = client.last_invocation + assert thread_id in client.call("thread.loaded.list")["data"] + rows = client.work() + threads = [row for row in rows if row["kind"] == "codex.app.thread"] + assert len(threads) == 1 and threads[0]["handles"]["thread_id"] == thread_id, rows + assert threads[0]["acquired_by"] == origin, rows + status = client.call("status") + native = status["process"] + assert native["state"] == "running" and native["parent_process_id"] == process.pid, status + owned = [native["process_id"]] + if native.get("supervisor_process_id") is not None: + owned.append(native["supervisor_process_id"]) + for pid in owned: + assert isinstance(pid, int) and pid > 1 and pid != process.pid, native + observations.append(ProcessObservation(pid)) + stopped = client.call("runtime.stop") + assert stopped["runtime_state"] == "stopped" and stopped["process"]["state"] == "stopped", stopped + if os.name == "nt": + assert stopped["process"]["cleanup_confirmed"] is True, stopped + for observation in observations: + observation.require_exited() + observation.close() + observations.clear() + assert client.work() == [], "Joined cleanup retained native work" + assert client.call("runtime.stop")["runtime_state"] == "stopped" + process.stdin.close() + assert process.wait(timeout=10) == 0 + client.reader.join(timeout=2) + assert not client.reader.is_alive(), "Protocol reader did not join EOF" + receipt["connections"].append({"protocol": initialized["protocolVersion"], + "tool_count": len(catalog), "thread_id": thread_id, "native_processes": owned, + "owned_stop": stopped["process"], "adapter_exit_code": process.returncode}) + confirmed = True + if app_local_runtime: + receipt["app_local_runtime_verified"] = True + except Exception as error: + receipt["failure_phase"] = phase + receipt["error"] = str(error)[:4096] + raise RuntimeError(f"Native protocol check failed during {phase}; evidence retained at {root}: {error}") from error + finally: + if process and process.poll() is None: + if not process.stdin.closed: + process.stdin.close() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + for observation in observations: + observation.close() + receipt["success"] = confirmed + receipt["protocol_success"] = confirmed + receipt["adapter_exit_code"] = process.poll() if process else None + stderr_path = root / "adapter-stderr.log" + if stderr_path.exists(): + with stderr_path.open("rb") as handle: + stderr_bytes = handle.read(16385) + receipt["stderr"] = stderr_bytes[:16384].decode("utf-8", errors="replace") + receipt["stderr_truncated"] = len(stderr_bytes) > 16384 + cleanup_error = None + if confirmed: + try: + receipt["read_only_files_removed"] = remove_private_state(root) + receipt["private_state_removed"] = True + except Exception as error: + cleanup_error = error + receipt["success"] = False + receipt["private_state_removed"] = False + receipt["failure_phase"] = "private-state cleanup" + receipt["error"] = str(error)[:4096] + if evidence_directory is not None: + (evidence_directory / "receipt.json").write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8") + if not receipt["success"]: + print(json.dumps(receipt, indent=2), flush=True) + if cleanup_error is not None: + raise RuntimeError(f"Native protocol passed but private-state cleanup failed at {root}") from cleanup_error + return receipt + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--adapter", type=Path, required=True) + parser.add_argument("--codex", type=Path, required=True) + parser.add_argument("--evidence-directory", type=Path) + parser.add_argument("--app-local-runtime", action="store_true") + options = parser.parse_args() + for executable in [options.adapter, options.codex]: + if not executable.is_absolute() or not executable.is_file(): + parser.error("Executables must be existing absolute file paths") + print(json.dumps(run(options.adapter, options.codex, options.evidence_directory, + options.app_local_runtime), indent=2)) diff --git a/Tests/WindowsAdapter/README.md b/Tests/WindowsAdapter/README.md new file mode 100644 index 0000000..8b5bf72 --- /dev/null +++ b/Tests/WindowsAdapter/README.md @@ -0,0 +1,35 @@ +# Linked native adapter acceptance + +The complete Windows candidate audit links `codex-mcp-adapter.exe`, copies the +executable and actual SwiftPM resource directories to a new path, records their +hashes, and runs help, version and the standard MCP protocol check. The native +Codex release archive is pinned by checksum and used only in the disposable +candidate; it is neither installed nor included in the plugin payload. + +The audit uses the adapter's shipping dependency graph. The requested SDK +revision must equal the lock; resolved SDK/MCP checkout identities and the +unchanged shipping lock are verified before building. No editable dependency +override is part of this acceptance. + +`ProtocolCheck.py` uses the existing workflow's standard MCP client with two +successive adapter connections, isolated homes and no inherited credentials. +It verifies catalog/resource discovery, database availability, real App Server +thread creation, creator correlation, process ownership, idempotent runtime stop, +joined native exit, work retirement and MCP EOF. On Windows, a synchronization +handle retains each observed process identity through shutdown. It does not +start model turns or establish authenticated-model acceptance. + +The selected Swift toolchain supplies runtime DLL lookup through PATH. This +check is independent of the release archive and clean-machine runtime contract; +it does not establish that an artifact contains all redistributable libraries. +The same protocol check can run on macOS against existing candidate executables +to validate its platform-neutral assertions. + +The `artifact` validation scope accepts a completed source-audit run and exact +SDK revision. It verifies that run belongs to this repository and checks every +linked payload file against the source inventory before executing the same bytes +in a fresh runner. The receipt binds the binary/source revision separately from +the check-definition revision. This supports runtime diagnosis and acceptance +without rebuilding unchanged source; it never promotes a failed source run to +a successful release. Failure evidence includes the phase, adapter exit status +and bounded stderr. Private databases and credentials are not uploaded. diff --git a/Tests/WindowsAdapter/codex-binary.json b/Tests/WindowsAdapter/codex-binary.json new file mode 100644 index 0000000..a1e9d90 --- /dev/null +++ b/Tests/WindowsAdapter/codex-binary.json @@ -0,0 +1,7 @@ +{ + "version": "0.154.0", + "archiveURL": "https://github.com/openai/codex/releases/download/rust-v0.154.0/codex-x86_64-pc-windows-msvc.exe.zip", + "archiveSHA256": "53685f9f6bd171d4bd7d6c2be724fc04f6737a4001eb8471ec59824e5adc8042", + "executable": "codex-x86_64-pc-windows-msvc.exe", + "executableSHA256": "be96b992178b1e467c225800da0d65f2c86d5eba1ef0b14632f65db381cbdfde" +} diff --git a/Tests/WindowsHostMCP/HostPipeFixture.swift b/Tests/WindowsHostMCP/HostPipeFixture.swift new file mode 100644 index 0000000..9fafe08 --- /dev/null +++ b/Tests/WindowsHostMCP/HostPipeFixture.swift @@ -0,0 +1,52 @@ +#if os(Windows) + import Foundation + import MCP + import WinSDK + + @main + struct HostPipeFixture { + static func main() async { + do { try await run() } catch { ExitProcess(10) } + } + + static func run() async throws { + let environment = ProcessInfo.processInfo.environment + guard let endpoint = try MCPInheritedPipeEndpoint.inherited(environment: environment) else { + ExitProcess(11) + } + // If this unrelated inheritable event escaped the host's explicit list, + // setting it is observable in the parent irrespective of handle-number reuse. + if let text = environment["FIXTURE_EXCLUDED_EVENT"], let value = UInt(text), + let handle = HANDLE(bitPattern: value) + { + _ = SetEvent(handle) + } + let transport = try MCPInheritedPipeTransport(takingOwnershipOf: endpoint) + var flags: DWORD = 0 + guard try endpoint.input.withHandle({ GetHandleInformation($0, &flags) }), + flags & DWORD(HANDLE_FLAG_INHERIT) == 0, + try endpoint.output.withHandle({ GetHandleInformation($0, &flags) }), + flags & DWORD(HANDLE_FLAG_INHERIT) == 0 + else { ExitProcess(12) } + let client = Client(name: "independent-plugin", version: "1") + do { + let initialized = try await client.connect(transport: transport) + guard initialized.serverInfo.name == "independent-host" else { ExitProcess(13) } + let request: RequestContext = try await client.callTool( + name: "echo", + arguments: ["value": .int(Int.max), "pid": .int(Int(GetCurrentProcessId()))]) + guard try await request.value.structuredContent?.objectValue?["echo"] == .int(Int.max) + else { ExitProcess(14) } + guard try FileHandle.standardInput.readToEnd()?.isEmpty != false else { ExitProcess(15) } + try FileHandle.standardOutput.write(contentsOf: Data("fixture-stdout\n".utf8)) + try FileHandle.standardError.write(contentsOf: Data("fixture-stderr\n".utf8)) + } catch { + await client.disconnect() + await transport.disconnect() + throw error + } + await client.disconnect() + await transport.disconnect() + } + } +#endif diff --git a/Tests/WindowsHostMCP/HostPipeTests.swift b/Tests/WindowsHostMCP/HostPipeTests.swift new file mode 100644 index 0000000..04f477f --- /dev/null +++ b/Tests/WindowsHostMCP/HostPipeTests.swift @@ -0,0 +1,331 @@ +#if os(Windows) + import Foundation + import HostProcess + import MCP + import Testing + import WinSDK + + @testable import HostPipe + + @Suite("Native inherited host MCP pipes", .serialized, .timeLimit(.minutes(1))) + struct HostPipeTests { + @Test("Standard MCP callbacks retain exact integer payloads") + func standardMCP() async throws { + let pair = try PipePair() + let serverTransport = try MCPInheritedPipeTransport(takingOwnershipOf: pair.left) + let clientTransport = try MCPInheritedPipeTransport(takingOwnershipOf: pair.right) + let server = Server(name: "host-pipe", version: "1", capabilities: .init(tools: .init())) + await server.withMethodHandler(CallTool.self) { request in + try CallTool.Result( + content: [], + structuredContent: Value.object(["echo": request.arguments?["value"] ?? .null])) + } + let client = Client(name: "owned-plugin", version: "1") + do { + try await server.start(transport: serverTransport) + let initialized = try await client.connect(transport: clientTransport) + #expect(initialized.serverInfo.name == "host-pipe") + let request: RequestContext = try await client.callTool( + name: "echo", arguments: ["value": .int(Int.max)]) + #expect(try await request.value.structuredContent?.objectValue?["echo"] == .int(Int.max)) + } catch { + await client.disconnect() + await server.stop() + await clientTransport.disconnect() + await serverTransport.disconnect() + throw error + } + await client.disconnect() + await server.stop() + await clientTransport.disconnect() + await serverTransport.disconnect() + } + + @Test( + "Independent child uses only inherited callback pipes and keeps standard streams separate") + func independentChild() async throws { + let pair = try PipePair() + let transport = try MCPInheritedPipeTransport(takingOwnershipOf: pair.left) + let child = try NativeHostChild(endpoint: pair.right) + let pid = child.pid + #expect(pid != GetCurrentProcessId()) + let server = Server( + name: "independent-host", version: "1", capabilities: .init(tools: .init())) + await server.withMethodHandler(CallTool.self) { request in + #expect(request.arguments?["pid"] == .int(Int(pid))) + return CallTool.Result( + content: [], structuredContent: .object(["echo": request.arguments?["value"] ?? .null])) + } + do { + try await server.start(transport: transport) + #expect(try await child.wait() == 0) + #expect(child.output(standardError: false) == "fixture-stdout\n") + #expect(child.output(standardError: true) == "fixture-stderr\n") + #expect(!child.excludedEventWasInherited) + } catch { + #expect(child.stop()) + await server.stop() + await transport.disconnect() + throw error + } + await server.stop() + await transport.disconnect() + } + + @Test("Stopping an independent child blocked in initialization releases peer EOF") + func stoppedChildReleasesEndpoint() async throws { + let pair = try PipePair() + let transport = try MCPInheritedPipeTransport(takingOwnershipOf: pair.left) + let child = try NativeHostChild(endpoint: pair.right) + do { + try await transport.connect() + var iterator = await transport.receive().makeAsyncIterator() + let request = try #require(try await iterator.next()) + #expect(String(decoding: request, as: UTF8.self).contains("initialize")) + #expect(child.stop()) + #expect(try await child.wait() != 0) + #expect(try await iterator.next() == nil) + #expect(!child.excludedEventWasInherited) + } catch { + #expect(child.stop()) + await transport.disconnect() + throw error + } + await transport.disconnect() + } + + @Test("Connected transport consumes original endpoints and concurrent close reaches peer EOF") + func endpointOwnership() async throws { + let pair = try PipePair() + let left = try MCPInheritedPipeTransport(takingOwnershipOf: pair.left) + let right = try MCPInheritedPipeTransport(takingOwnershipOf: pair.right) + do { + try await left.connect() + try await right.connect() + #expect(throws: (any Error).self) { try pair.left.output.withHandle { _ in } } + #expect(throws: (any Error).self) { try pair.left.input.withHandle { _ in } } + async let first: Void = left.disconnect() + async let second: Void = left.disconnect() + _ = await (first, second) + var iterator = await right.receive().makeAsyncIterator() + #expect(try await iterator.next() == nil) + await #expect(throws: (any Error).self) { try await left.connect() } + } catch { + await left.disconnect() + await right.disconnect() + throw error + } + await right.disconnect() + } + + @Test("Inherited handles require exact paired provenance and lose inheritance on admission") + func inheritedAdmission() async throws { + let pair = try PipePair() + let input = try HandleLease(duplicating: pair.left.input) + let output = try HandleLease(duplicating: pair.left.output) + let environment = [ + "COMPUTER_MCP_HOST_CONTEXT": "bound-by-host", + MCPInheritedPipeEndpoint.readEnvironmentKey: input.text, + MCPInheritedPipeEndpoint.writeEnvironmentKey: output.text, + ] + let endpoint = try #require(try MCPInheritedPipeEndpoint.inherited(environment: environment)) + input.transfer() + output.transfer() + let transport = try MCPInheritedPipeTransport(takingOwnershipOf: endpoint) + var inputFlags: DWORD = 0 + var outputFlags: DWORD = 0 + #expect(try endpoint.input.withHandle { GetHandleInformation($0, &inputFlags) }) + #expect(try endpoint.output.withHandle { GetHandleInformation($0, &outputFlags) }) + #expect(inputFlags & DWORD(HANDLE_FLAG_INHERIT) == 0) + #expect(outputFlags & DWORD(HANDLE_FLAG_INHERIT) == 0) + await transport.disconnect() + #expect(throws: (any Error).self) { try endpoint.output.withHandle { _ in } } + } + + @Test("Missing, ambiguous, standard-stream and noninherited handles are not consumed") + func rejectedAdmission() throws { + let pair = try PipePair() + #expect(try MCPInheritedPipeEndpoint.inherited(environment: [:]) == nil) + let values = ["", "0", "-1", "01", " 12", "12x", String(UInt.max)] + for value in values { + #expect(throws: (any Error).self) { + try MCPInheritedPipeEndpoint.inherited(environment: [ + "COMPUTER_MCP_HOST_CONTEXT": "bound-by-host", + MCPInheritedPipeEndpoint.readEnvironmentKey: value, + MCPInheritedPipeEndpoint.writeEnvironmentKey: "1", + ]) + } + } + let read = try pair.left.input.withHandle { String(UInt(bitPattern: $0)) } + let write = try pair.left.output.withHandle { String(UInt(bitPattern: $0)) } + let environment = [ + "COMPUTER_MCP_HOST_CONTEXT": "bound-by-host", + MCPInheritedPipeEndpoint.readEnvironmentKey: read, + MCPInheritedPipeEndpoint.writeEnvironmentKey: write, + ] + #expect(throws: (any Error).self) { + try MCPInheritedPipeEndpoint.inherited(environment: environment) + } + var ambiguous = environment + ambiguous[MCPInheritedPipeEndpoint.readEnvironmentKey.lowercased()] = read + #expect(throws: (any Error).self) { + try MCPInheritedPipeEndpoint.inherited(environment: ambiguous) + } + var standard = environment + let standardInput = try #require(GetStdHandle(STD_INPUT_HANDLE)) + standard[MCPInheritedPipeEndpoint.readEnvironmentKey] = String( + UInt(bitPattern: standardInput)) + #expect(throws: (any Error).self) { + try MCPInheritedPipeEndpoint.inherited(environment: standard) + } + var flags: DWORD = 0 + #expect(try pair.left.input.withHandle { GetHandleInformation($0, &flags) }) + #expect(try pair.left.output.withHandle { GetHandleInformation($0, &flags) }) + } + + @Test("A pre-cancelled receiver releases native endpoints and permits peer EOF") + func cancelledReceive() async throws { + let pair = try PipePair() + let left = try MCPInheritedPipeTransport(takingOwnershipOf: pair.left) + let right = try MCPInheritedPipeTransport(takingOwnershipOf: pair.right) + do { + try await left.connect() + try await right.connect() + let stream = await left.receive() + let cancelled = Task { + withUnsafeCurrentTask { $0?.cancel() } + var iterator = stream.makeAsyncIterator() + _ = try? await iterator.next() + } + await cancelled.value + var iterator = await right.receive().makeAsyncIterator() + #expect(try await iterator.next() == nil) + } catch { + await left.disconnect() + await right.disconnect() + throw error + } + await left.disconnect() + await right.disconnect() + } + } + + private struct PipePair: Sendable { + let left: MCPInheritedPipeEndpoint + let right: MCPInheritedPipeEndpoint + init() throws { + func pipe() throws -> (MCPInheritedPipeHandle, MCPInheritedPipeHandle) { + var read: HANDLE? + var write: HANDLE? + guard CreatePipe(&read, &write, nil, 4096), let read, let write else { + throw MCPError.connectionClosed + } + return ( + MCPInheritedPipeHandle(takingOwnershipOf: read), + MCPInheritedPipeHandle(takingOwnershipOf: write) + ) + } + let request = try pipe() + let response = try pipe() + left = MCPInheritedPipeEndpoint(input: request.0, output: response.1) + right = MCPInheritedPipeEndpoint(input: response.0, output: request.1) + } + } + + /// The native fixture owns one Job Object and never borrows the test runner's standard streams. + private final class NativeHostChild { + private let process: OpaquePointer + private let excluded: MCPInheritedPipeHandle + var pid: UInt32 { hmcp_pid(process) } + + init(endpoint: MCPInheritedPipeEndpoint) throws { + defer { + endpoint.input.close() + endpoint.output.close() + } + var security = SECURITY_ATTRIBUTES() + security.nLength = DWORD(MemoryLayout.size) + security.bInheritHandle = true + let event = try #require(CreateEventW(&security, true, false, nil)) + excluded = MCPInheritedPipeHandle(takingOwnershipOf: event) + let environment = ProcessInfo.processInfo.environment + let executable = try #require(environment["HOST_PIPE_FIXTURE_PATH"]) + var values = environment.filter { + ![ + "COMPUTER_MCP_HOST_CONTEXT", "COMPUTER_MCP_HOST_FD", + MCPInheritedPipeEndpoint.readEnvironmentKey, MCPInheritedPipeEndpoint.writeEnvironmentKey, + ] + .contains($0.key.uppercased()) + } + values["COMPUTER_MCP_HOST_CONTEXT"] = "bound-by-fixture-host" + values["FIXTURE_EXCLUDED_EVENT"] = String(UInt(bitPattern: event)) + var failure: UInt32 = 0 + process = try endpoint.input.withHandle { input in + try endpoint.output.withHandle { output in + #expect( + SetHandleInformation(input, DWORD(HANDLE_FLAG_INHERIT), DWORD(HANDLE_FLAG_INHERIT))) + #expect( + SetHandleInformation(output, DWORD(HANDLE_FLAG_INHERIT), DWORD(HANDLE_FLAG_INHERIT))) + values[MCPInheritedPipeEndpoint.readEnvironmentKey] = String(UInt(bitPattern: input)) + values[MCPInheritedPipeEndpoint.writeEnvironmentKey] = String(UInt(bitPattern: output)) + var block = + Array( + values.sorted { $0.key.lowercased() < $1.key.lowercased() } + .map { "\($0.key)=\($0.value)" }.joined(separator: "\0").utf16) + [0, 0] + let launched = executable.withCString(encodedAs: UTF16.self) { path in + hmcp_launch(path, &block, UInt(bitPattern: input), UInt(bitPattern: output), &failure) + } + return try #require(launched, "Native child launch failed: \(failure)") + } + } + } + + var excludedEventWasInherited: Bool { + (try? excluded.withHandle { WaitForSingleObject($0, 0) }) != DWORD(WAIT_TIMEOUT) + } + + func wait() async throws -> UInt32 { + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while true { + var exit: UInt32 = 0 + switch hmcp_poll(process, &exit) { + case 1: return exit + case 0: break + default: throw MCPError.internalError("Cannot observe fixture process exit") + } + guard ContinuousClock.now < deadline else { + throw MCPError.internalError("Fixture process exit timed out") + } + try await Task.sleep(for: .milliseconds(10)) + } + } + + func stop() -> Bool { hmcp_stop(process) != 0 } + func output(standardError: Bool) -> String { + var bytes = [UInt8](repeating: 0, count: 1024) + let count = hmcp_output(process, standardError ? 1 : 0, &bytes, bytes.count) + return String(decoding: bytes.prefix(count), as: UTF8.self) + } + deinit { hmcp_destroy(process) } + } + + /// A duplicate is transferred exactly once; failed admission leaves cleanup with the fixture. + private final class HandleLease { + private var owned: HANDLE? + let text: String + init(duplicating file: MCPInheritedPipeHandle) throws { + var copy: HANDLE? + guard + try file.withHandle({ + DuplicateHandle( + GetCurrentProcess(), $0, GetCurrentProcess(), ©, 0, true, + DWORD(DUPLICATE_SAME_ACCESS)) + }), let copy + else { throw MCPError.connectionClosed } + owned = copy + text = String(UInt(bitPattern: copy)) + } + func transfer() { owned = nil } + deinit { if let owned { CloseHandle(owned) } } + } +#endif diff --git a/Tests/WindowsHostMCP/HostProcess/HostProcess.c b/Tests/WindowsHostMCP/HostProcess/HostProcess.c new file mode 100644 index 0000000..403daa2 --- /dev/null +++ b/Tests/WindowsHostMCP/HostProcess/HostProcess.c @@ -0,0 +1,131 @@ +#include "HostProcess.h" +#include +#include +#include + +struct HMCPChild { + HANDLE process, job, output, error; + DWORD pid; +}; + +static void close_handle(HANDLE *handle) { + if (*handle) CloseHandle(*handle); + *handle = NULL; +} + +static BOOL pipe_pair(HANDLE *read, HANDLE *write) { + SECURITY_ATTRIBUTES security = { sizeof(security), NULL, TRUE }; + if (!CreatePipe(read, write, &security, 4096)) return FALSE; + return SetHandleInformation(*read, HANDLE_FLAG_INHERIT, 0); +} + +// The fixture host lends precisely two callback endpoints to a suspended child. +// Standard streams are separate. No name, listener, or credential is created. +HMCPChild *hmcp_launch(const wchar_t *executable, wchar_t *environment, + uintptr_t input, uintptr_t output, uint32_t *error) { + HMCPChild *child = calloc(1, sizeof(*child)); + HANDLE stdin_read = NULL, stdin_write = NULL, stdout_write = NULL, stderr_write = NULL; + PROCESS_INFORMATION process = {0}; + STARTUPINFOEXW startup = {0}; + SIZE_T size = 0; + BOOL initialized = FALSE; + wchar_t *command = NULL; + DWORD failure = ERROR_NOT_ENOUGH_MEMORY; + if (!child) goto cleanup; + child->job = CreateJobObjectW(NULL, NULL); + if (!child->job) goto failed; + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {0}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!SetInformationJobObject(child->job, JobObjectExtendedLimitInformation, + &limits, sizeof(limits))) goto failed; + if (!pipe_pair(&child->output, &stdout_write) || + !pipe_pair(&child->error, &stderr_write) || + !pipe_pair(&stdin_read, &stdin_write)) goto failed; + close_handle(&stdin_write); + if (!SetHandleInformation(stdin_read, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)) goto failed; + InitializeProcThreadAttributeList(NULL, 1, 0, &size); + startup.lpAttributeList = malloc(size); + if (!startup.lpAttributeList) goto cleanup; + if (!InitializeProcThreadAttributeList(startup.lpAttributeList, 1, 0, &size)) goto failed; + initialized = TRUE; + HANDLE inherited[] = { stdin_read, stdout_write, stderr_write, (HANDLE)input, (HANDLE)output }; + if (!UpdateProcThreadAttribute(startup.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inherited, sizeof(inherited), NULL, NULL)) goto failed; + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = stdin_read; + startup.StartupInfo.hStdOutput = stdout_write; + startup.StartupInfo.hStdError = stderr_write; + size_t count = wcslen(executable); + command = calloc(count + 3, sizeof(wchar_t)); + if (!command) goto cleanup; + command[0] = L'"'; + memcpy(command + 1, executable, count * sizeof(wchar_t)); + command[count + 1] = L'"'; + if (!CreateProcessW(executable, command, NULL, NULL, TRUE, + CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT, + environment, NULL, &startup.StartupInfo, &process)) goto failed; + child->process = process.hProcess; + child->pid = process.dwProcessId; + if (!AssignProcessToJobObject(child->job, child->process)) goto failed; + if (ResumeThread(process.hThread) == (DWORD)-1) goto failed; + failure = ERROR_SUCCESS; + goto cleanup; +failed: + failure = GetLastError(); +cleanup: + close_handle(&process.hThread); + close_handle(&stdin_read); + close_handle(&stdin_write); + close_handle(&stdout_write); + close_handle(&stderr_write); + if (initialized) DeleteProcThreadAttributeList(startup.lpAttributeList); + free(startup.lpAttributeList); + free(command); + *error = failure; + if (failure != ERROR_SUCCESS) { + if (child && child->process) { + TerminateProcess(child->process, 1); + WaitForSingleObject(child->process, 5000); + } + hmcp_destroy(child); + return NULL; + } + return child; +} + +uint32_t hmcp_pid(HMCPChild *child) { return child->pid; } + +int hmcp_poll(HMCPChild *child, uint32_t *exit_code) { + DWORD state = WaitForSingleObject(child->process, 0); + if (state == WAIT_TIMEOUT) return 0; + DWORD code = 0; + if (state != WAIT_OBJECT_0 || !GetExitCodeProcess(child->process, &code)) return -1; + *exit_code = code; + return 1; +} + +int hmcp_stop(HMCPChild *child) { + if (!child) return 1; + if (!TerminateJobObject(child->job, 1)) return 0; + return WaitForSingleObject(child->process, 5000) == WAIT_OBJECT_0; +} + +size_t hmcp_output(HMCPChild *child, int standard_error, void *bytes, size_t capacity) { + HANDLE pipe = standard_error ? child->error : child->output; + DWORD available = 0, count = 0; + if (!PeekNamedPipe(pipe, NULL, 0, NULL, &available, NULL)) return 0; + DWORD size = available < capacity ? available : (DWORD)capacity; + if (size == 0 || !ReadFile(pipe, bytes, size, &count, NULL)) return 0; + return count; +} + +void hmcp_destroy(HMCPChild *child) { + if (!child) return; + if (child->process) hmcp_stop(child); + close_handle(&child->process); + close_handle(&child->job); + close_handle(&child->output); + close_handle(&child->error); + free(child); +} diff --git a/Tests/WindowsHostMCP/HostProcess/include/HostProcess.h b/Tests/WindowsHostMCP/HostProcess/include/HostProcess.h new file mode 100644 index 0000000..923fc6a --- /dev/null +++ b/Tests/WindowsHostMCP/HostProcess/include/HostProcess.h @@ -0,0 +1,13 @@ +#pragma once +#include +#include +#include + +typedef struct HMCPChild HMCPChild; +HMCPChild *hmcp_launch(const wchar_t *executable, wchar_t *environment, + uintptr_t input, uintptr_t output, uint32_t *error); +uint32_t hmcp_pid(HMCPChild *child); +int hmcp_poll(HMCPChild *child, uint32_t *exit_code); +int hmcp_stop(HMCPChild *child); +size_t hmcp_output(HMCPChild *child, int standard_error, void *bytes, size_t capacity); +void hmcp_destroy(HMCPChild *child); diff --git a/Tests/WindowsHostMCP/Package.swift.template b/Tests/WindowsHostMCP/Package.swift.template new file mode 100644 index 0000000..dcee405 --- /dev/null +++ b/Tests/WindowsHostMCP/Package.swift.template @@ -0,0 +1,28 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "WindowsHostMCPValidation", + dependencies: [ + .package(url: "__MCP_URL__", exact: "__MCP_VERSION__"), + .package(url: "https://github.com/apple/swift-system.git", exact: "1.8.1"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), + ], + targets: [ + .target( + name: "HostPipe", + dependencies: [ + .product(name: "MCP", package: "swift-sdk"), + .product(name: "SystemPackage", package: "swift-system"), + .product(name: "Logging", package: "swift-log"), + ]), + .executableTarget( + name: "HostPipeFixture", + dependencies: [ + .product(name: "MCP", package: "swift-sdk"), + .product(name: "SystemPackage", package: "swift-system"), + .product(name: "Logging", package: "swift-log"), + ]), + .target(name: "HostProcess"), + .testTarget(name: "HostPipeTests", dependencies: ["HostPipe", "HostProcess"]), + ]) diff --git a/Tests/WindowsHostMCP/README.md b/Tests/WindowsHostMCP/README.md new file mode 100644 index 0000000..e92da9b --- /dev/null +++ b/Tests/WindowsHostMCP/README.md @@ -0,0 +1,32 @@ +# Native host MCP transport validation + +`Scripts/test-windows-host-mcp.ps1` copies the exact production inherited-pipe +transport, records its source hash, and resolves the versioned MCP dependency +from the shipping lock. Every selected dependency must match the shipping +location, version and revision. Swift 6.2.3 Windows debug/release tests +exercise actual anonymous pipes, standard MCP calls and exact integers, +endpoint ownership transfer, concurrent close/EOF, inherited-handle admission, +rejected environment/handle inputs and pre-cancelled receive cleanup. + +An independent native child consumes the same production transport. The fixture +host launches it suspended, assigns an owned Job Object, and passes exactly the +paired callback handles plus separate standard streams through +`PROC_THREAD_ATTRIBUTE_HANDLE_LIST`. Tests verify the native child PID, exact +integer callback, standard-stream isolation, exclusion of another inheritable +event, and peer EOF after terminating a child blocked in initialization. The +fixture host closes its child-side copies immediately after launch. + +These tests exercise native launch and transport ownership. They do not claim +a complete adapter artifact, a Windows Computer MCP host application, or +authenticated model execution. The process suite independently checks that +callback metadata is stripped from vendor environments. + +Run with an unchanged SDK checkout matching the shipping lock and a fresh output directory: + +```powershell +./Scripts/test-windows-host-mcp.ps1 -SDKPath -OutputDirectory +``` + +The validation workflow accepts `windows_validation_scope: host-mcp` with an +exact locked SDK revision to run this gate independently. Its default `all` +scope retains the complete source, database, process and transport checks. diff --git a/Tests/WindowsProcess/CommandRunnerTests.swift b/Tests/WindowsProcess/CommandRunnerTests.swift new file mode 100644 index 0000000..e73a202 --- /dev/null +++ b/Tests/WindowsProcess/CommandRunnerTests.swift @@ -0,0 +1,215 @@ +import Foundation +import Testing +import WinSDK + +@testable import ManagedProcess + +@Suite("Windows adapter finite commands", .timeLimit(.minutes(1))) +struct CommandRunnerTests { + @Test( + "Native argv, Unicode cwd, overridden environment and stdin EOF survive synchronous execution") + func launchContext() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let arguments = ["", "a b", "汉字 🐈", "quote\"inside", "trailing slash \\", "&|<>%"] + let executable = try fixture() + let runner = ProcessCommandRunner( + environment: environment(mode: "echo").merging(["Value": "base"]) { _, new in new }) + let result = try await Task.detached { + try runner.runData( + executable: executable.path, arguments: arguments, workingDirectory: directory, + environment: ["VALUE": "覆盖"], timeoutMilliseconds: 10_000, maxOutputBytes: 16_384) + }.value + let value = try JSONDecoder().decode(JSONValue.self, from: result.stdout) + #expect(value.objectValue?["arguments"] == .array(arguments.map(JSONValue.string))) + let reportedDirectory = try #require(value.objectValue?["cwd"]?.stringValue) + #expect(try directoryIdentity(reportedDirectory) == directoryIdentity(directory.path)) + #expect(value.objectValue?["value"] == .string("覆盖")) + #expect(value.objectValue?["input"] == .string("")) + #expect(result.exitCode == 0 && !result.timedOut) + #expect(!result.stdoutTruncated && !result.stderrTruncated) + } + + @Test("Both output pipes drain beyond their retained prefixes") + func boundedOutput() async throws { + let result = try await ProcessCommandRunner(environment: environment(mode: "output")) + .runDataAsync( + executable: fixture().path, arguments: [], workingDirectory: nil, environment: [:], + timeoutMilliseconds: 10_000, maxOutputBytes: 64) + #expect(result.stdout == Data(("kept\n" + String(repeating: "x", count: 59)).utf8)) + #expect(result.stderr == Data(repeating: 121, count: 64)) + #expect(result.stdoutTruncated && result.stderrTruncated) + #expect(result.exitCode == 0 && !result.timedOut) + } + + enum End: String, CaseIterable { case natural, timeout, cancellation } + + @Test( + "Every completion route joins descendants and preserves an independent command", + arguments: End.allCases) + func treeCleanup(_ end: End) async throws { + let directory = try temporaryDirectory() + let siblingDirectory = try temporaryDirectory() + defer { + try? FileManager.default.removeItem(at: directory) + try? FileManager.default.removeItem(at: siblingDirectory) + } + let executable = try fixture().path + let runner = ProcessCommandRunner( + environment: environment(mode: end == .natural ? "tree-exit" : "tree", directory: directory)) + let siblingRunner = ProcessCommandRunner( + environment: environment(mode: "tree", directory: siblingDirectory)) + let command = Task { + try await runner.runDataAsync( + executable: executable, arguments: [], workingDirectory: directory, + environment: [:], timeoutMilliseconds: end == .timeout ? 5_000 : 20_000, maxOutputBytes: 64) + } + let sibling = Task { + try await siblingRunner.runDataAsync( + executable: executable, arguments: [], workingDirectory: siblingDirectory, + environment: [:], timeoutMilliseconds: 20_000, maxOutputBytes: 64) + } + do { + let members = try await observeTree(directory) + let others = try await observeTree(siblingDirectory) + if end == .natural { try Data().write(to: directory.appendingPathComponent("release")) } + if end == .cancellation { command.cancel() } + switch await command.result { + case .success(let result): + #expect(end != .cancellation) + #expect(result.timedOut == (end == .timeout)) + #expect(result.stdout == Data("ready\n".utf8)) + if end == .natural { #expect(result.exitCode == 0) } + case .failure(let error): + #expect(end == .cancellation && error is CancellationError) + } + #expect(members.allSatisfy { $0.hasExited }) + #expect(others.allSatisfy { !$0.hasExited }) + sibling.cancel() + await #expect(throws: CancellationError.self) { try await sibling.value } + #expect(others.allSatisfy { $0.hasExited }) + } catch { + command.cancel() + sibling.cancel() + _ = await command.result + _ = await sibling.result + throw error + } + } + + @Test("Codex and finite commands share explicit Windows PATH discovery") + func discovery() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let binaryDirectory = directory.appendingPathComponent("命令 tools") + try FileManager.default.createDirectory(at: binaryDirectory, withIntermediateDirectories: false) + let executable = binaryDirectory.appendingPathComponent("fixture.exe") + try FileManager.default.copyItem(at: fixture(), to: executable) + let environment = ["Path": "\"命令 tools\";;", "PATHEXT": ".cmd"] + let native = try WindowsExecutable.resolve( + "fixture", workspace: directory, environment: environment) + let configured = try CodexConfig(executable: "fixture").resolvedExecutableURL( + workspaceURL: directory, environment: environment) + #expect(native == configured && native == executable) + for invalid in ["C:fixture.exe", "\\fixture.exe", "NUL", "\\\\.\\pipe\\fixture"] { + #expect(throws: CommandRunnerError.self) { + try WindowsExecutable.resolve(invalid, workspace: directory, environment: environment) + } + } + #expect(throws: CommandRunnerError.self) { + try WindowsExecutable.resolve( + "fixture", workspace: directory, + environment: ["Path": binaryDirectory.path, "PATH": binaryDirectory.path]) + } + } + + @Test("Environment aliases and invalid execution limits fail before child launch") + func admission() async throws { + let merged = try WindowsProcessEnvironment.merging( + ["Path": "base", "=C:": "C:\\base"], overrides: ["PATH": "child"]) + #expect(merged == ["PATH": "child", "=C:": "C:\\base"]) + #expect(WindowsProcessEnvironment.namesMatch("path", "PATH")) + #expect(!WindowsProcessEnvironment.namesMatch("PATH\0extra", "PATH")) + for invalid in [["Path": "a", "PATH": "b"], ["=C:": "C:\\bad"], ["KEY": "a\0b"]] { + #expect(throws: CommandRunnerError.self) { + try WindowsProcessEnvironment.merging([:], overrides: invalid) + } + } + for (deadline, limit) in [(0, 64), (10_000, 0), (3_600_001, 64)] { + await #expect(throws: CommandRunnerError.self) { + try await ProcessCommandRunner().runDataAsync( + executable: "missing", arguments: [], + workingDirectory: nil, environment: [:], timeoutMilliseconds: deadline, + maxOutputBytes: limit) + } + } + } + + private func fixture() throws -> URL { + URL(fileURLWithPath: try #require(ProcessInfo.processInfo.environment["CODEX_WINDOWS_FIXTURE"])) + } + + private func environment(mode: String, directory: URL? = nil) -> [String: String] { + var result = ["CODEX_FIXTURE_MODE": mode] + for key in ["PATH", "SystemRoot"] { + if let entry = ProcessInfo.processInfo.environment.first(where: { + WindowsProcessEnvironment.namesMatch($0.key, key) + }) { + result[key] = entry.value + } + } + if let directory { result["CODEX_FIXTURE_DIRECTORY"] = directory.path } + return result + } + + private func temporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "command 汉字 \(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + return directory + } + + private func observeTree(_ directory: URL) async throws -> [Observation] { + let ready = directory.appendingPathComponent("root") + let deadline = ContinuousClock.now + .seconds(10) + while !FileManager.default.fileExists(atPath: ready.path) { + guard ContinuousClock.now < deadline else { throw ReadinessTimeout() } + try await Task.sleep(for: .milliseconds(10)) + } + return try ["root", "branch", "leaf"].map { name in + let value = try String(contentsOf: directory.appendingPathComponent(name), encoding: .utf8) + let pid = try #require(DWORD(value)) + return Observation(try #require(OpenProcess(DWORD(SYNCHRONIZE), false, pid))) + } + } + + private struct ReadinessTimeout: Error {} + + private struct DirectoryIdentity: Equatable { + let volume: UInt64 + let file: Data + } + + private func directoryIdentity(_ path: String) throws -> DirectoryIdentity { + let handle = try #require( + CreateFileW( + Array(path.utf16) + [0], DWORD(FILE_READ_ATTRIBUTES), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), nil, DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_BACKUP_SEMANTICS), nil)) + try #require(handle != INVALID_HANDLE_VALUE) + defer { CloseHandle(handle) } + var identity = FILE_ID_INFO() + try #require( + GetFileInformationByHandleEx( + handle, FileIdInfo, &identity, DWORD(MemoryLayout.size(ofValue: identity)))) + return DirectoryIdentity( + volume: identity.VolumeSerialNumber, file: withUnsafeBytes(of: identity.FileId) { Data($0) }) + } + + private final class Observation { + let handle: HANDLE + init(_ handle: HANDLE) { self.handle = handle } + deinit { CloseHandle(handle) } + var hasExited: Bool { WaitForSingleObject(handle, 0) == DWORD(WAIT_OBJECT_0) } + } +} diff --git a/Tests/WindowsProcess/ManagedProcessTests.swift b/Tests/WindowsProcess/ManagedProcessTests.swift new file mode 100644 index 0000000..c5e5352 --- /dev/null +++ b/Tests/WindowsProcess/ManagedProcessTests.swift @@ -0,0 +1,287 @@ +import CodexAppServerRuntime +import CodexAppServerStdio +import Foundation +import Testing +import WinSDK + +@testable import ManagedProcess + +@Suite("Windows adapter process ownership", .timeLimit(.minutes(1))) +struct ManagedProcessTests { + @Test("Host callback handles and provenance do not enter vendor environments") + func hostEnvironmentIsolation() { + let environment = CodexProcessEnvironment.resolved( + base: [ + "computer_mcp_host_context": "bound-by-host", "Computer_Mcp_Host_Fd": "3", + "computer_mcp_host_read_handle": "144", "COMPUTER_MCP_HOST_WRITE_HANDLE": "148", + "PRESERVE_VALUE": "user-owned", + ], systemProxy: .init()) + #expect(environment["PRESERVE_VALUE"] == "user-owned") + #expect(!environment.keys.contains { $0.lowercased().hasPrefix("computer_mcp_host_") }) + } + + @Test("Graceful EOF preserves final output and a cancelled close caller joins cleanup") + func gracefulClose() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let process = try makeProcess(mode: "input-finish", directory: directory, grace: 5_000) + do { + var lines = process.inboundLines.makeAsyncIterator() + try await process.sendLine("汉字 🐈") + #expect(try await lines.next() == "汉字 🐈") + let root = try observe("main", in: directory) + let closing = Task { await process.close() } + closing.cancel() + #expect(try await lines.next() == "eof") + await #expect(throws: ManagedLineProcessError.self) { try await process.sendLine("late") } + #expect(!root.hasExited) + #expect(await process.snapshot().cleanupConfirmed == false) + try Data().write(to: directory.appendingPathComponent("release")) + await closing.value + let stopped = await process.snapshot() + #expect(stopped.cleanupConfirmed == true && stopped.hasExited) + #expect(stopped.exitCode == 23 && !stopped.terminationEscalated) + #expect(stopped.processID == Int32(bitPattern: root.identifier)) + #expect(stopped.processGroupID == nil && stopped.supervisorProcessID == nil) + #expect(root.hasExited) + #expect(try await lines.next() == nil) + } catch { + await process.close() + throw error + } + } + + @Test("Forced close joins a blocked writer and concurrent observers") + func blockedInput() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let process = try makeProcess(mode: "blocked-input", directory: directory) + do { + var lines = process.inboundLines.makeAsyncIterator() + #expect(try await lines.next() == "ready") + let root = try observe("root", in: directory) + let sending = Task { try await process.sendLine(String(repeating: "x", count: 2_000_000)) } + #expect(try await lines.next() == "receiving") + async let first: Void = process.close() + async let second: Void = process.close() + await first + await second + guard case .failure = await sending.result else { + Issue.record("A blocked native pipe accepted the complete frame.") + return + } + let stopped = await process.snapshot() + #expect(stopped.cleanupConfirmed == true && stopped.hasExited) + #expect(stopped.terminationEscalated && stopped.pendingWrites == 0) + #expect(root.hasExited) + } catch { + await process.close() + throw error + } + } + + @Test("Natural root exit joins inherited descendants") + func naturalExit() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let process = try makeProcess(mode: "tree-exit", directory: directory) + do { + var lines = process.inboundLines.makeAsyncIterator() + #expect(try await lines.next() == "ready") + let members = try ["root", "branch", "leaf"].map { try observe($0, in: directory) } + try Data().write(to: directory.appendingPathComponent("release")) + #expect(try await lines.next() == nil) + try await waitForCleanup(process) + await process.close() + #expect(members.allSatisfy { $0.hasExited }) + #expect(await process.snapshot().exitCode == 0) + } catch { + await process.close() + throw error + } + } + + @Test("Owner exit cleans only its child tree and preserves an independent invocation") + func externalOwnerExit() async throws { + let firstDirectory = try temporaryDirectory() + let secondDirectory = try temporaryDirectory() + defer { + try? FileManager.default.removeItem(at: firstDirectory) + try? FileManager.default.removeItem(at: secondDirectory) + } + let owner = try CodexAppServerStdioTransport( + configuration: .init(executableURL: fixture(), environment: environment(mode: "lines"))) + do { + let first = try makeProcess( + mode: "tree", directory: firstDirectory, owner: owner.processIdentifier) + do { + let second = try makeProcess(mode: "tree", directory: secondDirectory) + do { + var firstLines = first.inboundLines.makeAsyncIterator() + var secondLines = second.inboundLines.makeAsyncIterator() + #expect(try await firstLines.next() == "ready") + #expect(try await secondLines.next() == "ready") + let firstMembers = try ["root", "branch", "leaf"].map { + try observe($0, in: firstDirectory) + } + let secondMembers = try ["root", "branch", "leaf"].map { + try observe($0, in: secondDirectory) + } + await owner.close() + try await waitForCleanup(first) + #expect(firstMembers.allSatisfy { $0.hasExited }) + #expect(secondMembers.allSatisfy { !$0.hasExited }) + #expect(await second.snapshot().cleanupConfirmed == false) + await second.close() + #expect(secondMembers.allSatisfy { $0.hasExited }) + } catch { + await second.close() + throw error + } + await first.close() + } catch { + await first.close() + throw error + } + } catch { + await owner.close() + throw error + } + await owner.close() + } + + @Test("A retained terminated owner fails before child admission") + func terminatedOwner() async throws { + let directory = try temporaryDirectory() + let childDirectory = try temporaryDirectory() + defer { + try? FileManager.default.removeItem(at: directory) + try? FileManager.default.removeItem(at: childDirectory) + } + let owner = try CodexAppServerStdioTransport( + configuration: .init( + executableURL: fixture(), environment: environment(mode: "lines", directory: directory))) + do { + var lines = owner.inboundLines.makeAsyncIterator() + try await owner.sendLine("ready") + #expect(try await lines.next() == "ready") + let retained = try observe("main", in: directory) + await owner.close() + #expect(retained.hasExited) + #expect(throws: ManagedLineProcessError.self) { + _ = try makeProcess( + mode: "lines", directory: childDirectory, owner: Int32(bitPattern: retained.identifier)) + } + #expect( + !FileManager.default.fileExists(atPath: childDirectory.appendingPathComponent("main").path)) + } catch { + await owner.close() + throw error + } + } + + @Test("The adapter's lower outgoing bound preserves the running process") + func outgoingBound() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let process = try makeProcess(mode: "lines", directory: directory, limit: 4) + do { + await #expect(throws: ManagedLineProcessError.self) { try await process.sendLine("🐈x") } + var lines = process.inboundLines.makeAsyncIterator() + try await process.sendLine("🐈") + #expect(try await lines.next() == "🐈") + #expect(await process.snapshot().state == .running) + await process.close() + #expect(await process.snapshot().cleanupConfirmed == true) + } catch { + await process.close() + throw error + } + } + + @Test("The adapter's lower incoming bound joins the process on overflow") + func incomingBound() async throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let process = try makeProcess(mode: "oversized-frame", directory: directory, limit: 64) + do { + var lines = process.inboundLines.makeAsyncIterator() + #expect(try await lines.next() == "ready") + let root = try observe("root", in: directory) + try await process.sendLine("start") + await #expect( + throws: CodexAppServerConnectionFoundation.FoundationError.messageTooLarge(limitBytes: 64) + ) { try await lines.next() } + try await waitForCleanup(process) + #expect(root.hasExited) + await process.close() + } catch { + await process.close() + throw error + } + } + + private func fixture() throws -> URL { + URL(fileURLWithPath: try #require(ProcessInfo.processInfo.environment["CODEX_WINDOWS_FIXTURE"])) + } + + private func makeProcess( + mode: String, directory: URL, grace: Int = 0, + limit: Int = 16 * 1_024 * 1_024, + owner: Int32 = ProcessInfo.processInfo.processIdentifier + ) throws -> ManagedLineProcess { + try ManagedLineProcess( + configuration: .init( + executable: fixture().path, environment: environment(mode: mode, directory: directory), + workingDirectory: directory, terminationGraceMilliseconds: grace, + killGraceMilliseconds: 5_000, maximumMessageBytes: limit, ownerProcessID: owner)) + } + + private func environment(mode: String, directory: URL? = nil) -> [String: String] { + var result = ["CODEX_FIXTURE_MODE": mode] + for key in ["PATH", "SystemRoot"] { + if let value = ProcessInfo.processInfo.environment.first(where: { + $0.key.caseInsensitiveCompare(key) == .orderedSame + })?.value { + result[key] = value + } + } + if let directory { result["CODEX_FIXTURE_DIRECTORY"] = directory.path } + return result + } + + private func temporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "adapter 汉字 \(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + return directory + } + + private func observe(_ name: String, in directory: URL) throws -> NativeProcessObservation { + let text = try String(contentsOf: directory.appendingPathComponent(name), encoding: .utf8) + let identifier = try #require(DWORD(text)) + let handle = try #require(OpenProcess(DWORD(SYNCHRONIZE), false, identifier)) + return NativeProcessObservation(handle: handle, identifier: identifier) + } + + private func waitForCleanup(_ process: ManagedLineProcess) async throws { + let deadline = ContinuousClock.now + .seconds(10) + while await process.snapshot().cleanupConfirmed != true { + guard ContinuousClock.now < deadline else { throw CleanupTimeout() } + try await Task.sleep(for: .milliseconds(10)) + } + } + + private struct CleanupTimeout: Error {} + + private final class NativeProcessObservation { + let handle: HANDLE + let identifier: DWORD + init(handle: HANDLE, identifier: DWORD) { + self.handle = handle + self.identifier = identifier + } + deinit { CloseHandle(handle) } + var hasExited: Bool { WaitForSingleObject(handle, 0) == DWORD(WAIT_OBJECT_0) } + } +} diff --git a/Tests/WindowsProcess/Package.swift.template b/Tests/WindowsProcess/Package.swift.template new file mode 100644 index 0000000..ee4f988 --- /dev/null +++ b/Tests/WindowsProcess/Package.swift.template @@ -0,0 +1,15 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "WindowsManagedProcessTests", + dependencies: [.package(name: "swift-codex", path: "__SDK_PATH__")], + targets: [ + .target( + name: "ManagedProcess", + dependencies: [ + .product(name: "CodexAppServerStdio", package: "swift-codex"), + .product(name: "CodexAppServerRuntime", package: "swift-codex"), + ]), + .testTarget(name: "ManagedProcessTests", dependencies: ["ManagedProcess"]), + ]) diff --git a/Tests/WindowsProcess/PrivateDirectoryTests.swift b/Tests/WindowsProcess/PrivateDirectoryTests.swift new file mode 100644 index 0000000..6f33726 --- /dev/null +++ b/Tests/WindowsProcess/PrivateDirectoryTests.swift @@ -0,0 +1,174 @@ +import Foundation +import Testing +import WinSDK + +@testable import ManagedProcess + +@Suite("Windows private state directories", .timeLimit(.minutes(1))) +struct PrivateDirectoryTests { + @Test("Creation and reopen preserve a protected owner DACL and private child inheritance") + func creationAndInheritance() throws { + let root = temporaryURL() + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("subjects/主体", isDirectory: true) + do { + let first = try WindowsPrivateDirectory(target) + let second = try WindowsPrivateDirectory(target) + defer { withExtendedLifetime((first, second)) {} } + let security = try snapshot(target) + #expect(security.protected) + #expect(security.grantees == [security.owner]) + #expect(security.masks == [DWORD(0x001F_01FF)]) + let file = target.appendingPathComponent("record.sqlite") + try Data("private".utf8).write(to: file) + let child = try snapshot(file) + #expect(child.grantees == [security.owner]) + #expect(child.masks == [DWORD(0x001F_01FF)]) + #expect(try Data(contentsOf: file) == Data("private".utf8)) + } + let reopened = try WindowsPrivateDirectory(target) + withExtendedLifetime(reopened) {} + } + + @Test("Existing broader permissions are rejected without changing ACLs or contents") + func existingDirectoryPreserved() throws { + let root = temporaryURL() + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + let file = root.appendingPathComponent("existing.txt") + try Data("unrelated".utf8).write(to: file) + let before = try snapshot(root) + #expect(throws: WindowsPrivateDirectoryError.self) { try WindowsPrivateDirectory(root) } + #expect(try snapshot(root) == before) + #expect(try Data(contentsOf: file) == Data("unrelated".utf8)) + } + + @Test("Retained ancestors prevent parent and state-directory replacement until release") + func retainsIdentity() throws { + let root = temporaryURL() + let replacement = root.appendingPathExtension("moved") + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: replacement) + } + let target = root.appendingPathComponent("state") + var guardDirectory: WindowsPrivateDirectory? = try WindowsPrivateDirectory(target) + try #require(!MoveFileW(Array(root.path.utf16) + [0], Array(replacement.path.utf16) + [0])) + #expect(GetLastError() == DWORD(ERROR_SHARING_VIOLATION)) + try #require( + !MoveFileW( + Array(target.path.utf16) + [0], + Array(target.appendingPathExtension("moved").path.utf16) + [0])) + #expect(GetLastError() == DWORD(ERROR_SHARING_VIOLATION)) + withExtendedLifetime(guardDirectory) {} + guardDirectory = nil + try #require(MoveFileW(Array(root.path.utf16) + [0], Array(replacement.path.utf16) + [0])) + } + + @Test("Directory links are rejected at the state root and within its ancestry") + func rejectsReparsePoints() throws { + let root = temporaryURL() + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + let destination = root.appendingPathComponent("destination") + do { + let directory = try WindowsPrivateDirectory(destination) + withExtendedLifetime(directory) {} + } + let link = root.appendingPathComponent("alias") + try #require( + CreateSymbolicLinkW( + Array(link.path.utf16) + [0], Array(destination.path.utf16) + [0], + DWORD(SYMBOLIC_LINK_FLAG_DIRECTORY | SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE)) != 0) + #expect(throws: WindowsPrivateDirectoryError.self) { try WindowsPrivateDirectory(link) } + #expect(throws: WindowsPrivateDirectoryError.self) { + try WindowsPrivateDirectory(link.appendingPathComponent("child")) + } + #expect( + !FileManager.default.fileExists(atPath: destination.appendingPathComponent("child").path)) + } + + @Test("File collisions remain untouched and system-owned directories are refused") + func refusesUnownedOrNonDirectory() throws { + let file = temporaryURL() + defer { try? FileManager.default.removeItem(at: file) } + try Data("file".utf8).write(to: file) + #expect(throws: WindowsPrivateDirectoryError.self) { try WindowsPrivateDirectory(file) } + #expect(try Data(contentsOf: file) == Data("file".utf8)) + var path = [WCHAR](repeating: 0, count: 32_768) + let count = GetWindowsDirectoryW(&path, UINT(path.count)) + try #require(count > 0 && count < path.count) + let system = URL(fileURLWithPath: String(decoding: path.prefix(Int(count)), as: UTF16.self)) + let before = try snapshot(system) + #expect(throws: WindowsPrivateDirectoryError.self) { try WindowsPrivateDirectory(system) } + #expect(try snapshot(system) == before) + } + + private func temporaryURL() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("private 汉字 \(UUID())") + } + + private struct Snapshot: Equatable { + let owner: String + let protected: Bool + let grantees: [String] + let masks: [DWORD] + let sddl: String + } + + private func snapshot(_ url: URL) throws -> Snapshot { + let handle = try #require( + CreateFileW( + Array(url.path.utf16) + [0], DWORD(READ_CONTROL), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), nil, DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT), nil)) + try #require(handle != INVALID_HANDLE_VALUE) + defer { CloseHandle(handle) } + var owner: PSID? + var acl: PACL? + var descriptor: PSECURITY_DESCRIPTOR? + let information = DWORD(OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION) + try #require( + GetSecurityInfo(handle, SE_FILE_OBJECT, information, &owner, nil, &acl, nil, &descriptor) + == DWORD(ERROR_SUCCESS)) + let value = try #require(descriptor) + defer { LocalFree(value) } + let ownerName = try sidString(try #require(owner)) + var control: SECURITY_DESCRIPTOR_CONTROL = 0 + var revision: DWORD = 0 + try #require(GetSecurityDescriptorControl(value, &control, &revision)) + var text: LPWSTR? + try #require( + ConvertSecurityDescriptorToStringSecurityDescriptorW( + value, DWORD(SDDL_REVISION_1), information, &text, nil)) + let string = try #require(text) + defer { LocalFree(string) } + var grantees: [String] = [] + var masks: [DWORD] = [] + if let acl { + for index in 0...offset(of: \.SidStart)!))) + masks.append(ace.Mask) + } + } + } + return Snapshot( + owner: ownerName, protected: control & SECURITY_DESCRIPTOR_CONTROL(SE_DACL_PROTECTED) != 0, + grantees: grantees, masks: masks, sddl: String(decodingCString: string, as: UTF16.self)) + } + + private func sidString(_ sid: PSID) throws -> String { + var text: LPWSTR? + try #require(ConvertSidToStringSidW(sid, &text)) + let value = try #require(text) + defer { LocalFree(value) } + return String(decodingCString: value, as: UTF16.self) + } +} diff --git a/Tests/WindowsProcess/README.md b/Tests/WindowsProcess/README.md new file mode 100644 index 0000000..fac117a --- /dev/null +++ b/Tests/WindowsProcess/README.md @@ -0,0 +1,36 @@ +# Native adapter process tests + +`Scripts/test-windows-process.ps1 -SDKPath SDK_CHECKOUT -OutputDirectory OUTPUT` +stages the exact production Windows process source and records its hashes. It +uses the complete swift-codex checkout matching the shipping lock and that SDK's +native process fixture. +Run it on Windows with the selected Swift toolchain. The output directory must +be new; the script restores its temporary environment settings. Native filesystem +sources are typechecked before SDK fixture builds so WinSDK import failures +produce immediate diagnostics. Both runtime configurations remain required. + +Debug and release test EOF completion, cancelled/concurrent shutdown callers, +blocked stdin, configured frame bounds, natural root exit with descendants, +external-owner death, and rejection of a terminated owner before child launch. +Observations retain exact native process handles and check sibling isolation. + +Finite-command tests also exercise synchronous execution, argument quoting, +Unicode working directories, environment overrides, stdin EOF, bounded output +with continued drain, and descendant cleanup after cancellation, timeout and +natural exit. Codex discovery and finite commands use the same Windows path and +environment rules. Each test retains its own native process observations. + +Private state tests inspect actual owner SIDs and protected DACLs, inherited +file permissions, directory replacement during retained ownership, and refusal +of reparses, foreign owners and existing broader permissions. The separate +GRDB audit verifies that connection configuration retains the production +directory guard through close and release. + +Managed-worktree tests run the production finite-command runner against real Git +repositories. They check private root inheritance, physical containment and +identity, retained parent ownership, dirty removal refusal, reparse rejection, +and absence verification without recreating missing content. + +These tests verify the adapter's lifecycle boundary. The complete adapter and +all dependency targets retain their separate source audit. Fixture results do +not establish standard-MCP packaging or authenticated model acceptance. diff --git a/Tests/WindowsProcess/WorktreeFileSystemTests.swift b/Tests/WindowsProcess/WorktreeFileSystemTests.swift new file mode 100644 index 0000000..3903450 --- /dev/null +++ b/Tests/WindowsProcess/WorktreeFileSystemTests.swift @@ -0,0 +1,176 @@ +import Foundation +import Testing +import WinSDK + +@testable import ManagedProcess + +@Suite("Windows managed worktree filesystem", .timeLimit(.minutes(2))) +struct WorktreeFileSystemTests { + private typealias FileSystem = CodexWorktreeFileSystem + + @Test("Real Git worktrees inherit private access, retain their parent and refuse dirty removal") + func gitLifecycle() throws { + let container = temporaryURL() + defer { try? FileManager.default.removeItem(at: container) } + let source = container.appendingPathComponent("source 汉字") + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try git(["init", "--initial-branch=main"], in: source) + try git( + [ + "-c", "user.name=Native Test", "-c", "user.email=native@example.invalid", "commit", + "--allow-empty", "-m", "fixture", + ], in: source) + let root = container.appendingPathComponent("managed") + let parent = root.appendingPathComponent("repository") + let target = parent.appendingPathComponent("worktree 汉字") + let rootProtection = try FileSystem.prepareRoot(root) + let parentProtection = try FileSystem.prepareParent(parent, containedIn: root) + defer { withExtendedLifetime((rootProtection, parentProtection)) {} } + try git(["worktree", "add", "-b", "candidate", target.path, "HEAD"], in: source) + try FileSystem.validateDirectory(target, containedIn: root) + let reported = try git(["rev-parse", "--show-toplevel"], in: target) + .stdout.trimmingCharacters(in: .whitespacesAndNewlines) + let canonical = try FileSystem.canonicalDirectory(reported, relativeTo: target) + #expect(try FileSystem.sameDirectory(canonical, target)) + let common = try git(["rev-parse", "--git-common-dir"], in: target) + .stdout.trimmingCharacters(in: .whitespacesAndNewlines) + #expect( + try FileSystem.sameDirectory( + FileSystem.canonicalDirectory(common, relativeTo: target), + source.appendingPathComponent(".git"))) + try #require( + !MoveFileW( + Array(parent.path.utf16) + [0], + Array(parent.appendingPathExtension("moved").path.utf16) + [0])) + #expect(GetLastError() == DWORD(ERROR_SHARING_VIOLATION)) + let dirty = target.appendingPathComponent("untracked.txt") + try Data("preserve".utf8).write(to: dirty) + let refused = try runGit(["worktree", "remove", target.path], in: source) + #expect(refused.exitCode != 0) + #expect(try Data(contentsOf: dirty) == Data("preserve".utf8)) + try FileManager.default.removeItem(at: dirty) + try git(["worktree", "remove", target.path], in: source) + #expect(try FileSystem.isAbsent(target)) + #expect(throws: WindowsPrivateDirectoryError.self) { + try FileSystem.validateDirectory(target, containedIn: root) + } + #expect(try FileSystem.isAbsent(target)) + try parentProtection.validate() + } + + @Test("Containment is physical and validation cannot create a missing or unrelated path") + func containmentAndInspection() throws { + let container = temporaryURL() + defer { try? FileManager.default.removeItem(at: container) } + let root = container.appendingPathComponent("managed") + let other = container.appendingPathComponent("managed-other") + let protection = try FileSystem.prepareRoot(root) + let otherProtection = try FileSystem.prepareRoot(other) + defer { withExtendedLifetime((protection, otherProtection)) {} } + #expect(try !FileSystem.isDescendant(other, of: root)) + #expect(try !FileSystem.sameDirectory(other, root)) + #expect(throws: WindowsPrivateDirectoryError.self) { + try FileSystem.validateDirectory(other, containedIn: root) + } + let missing = root.appendingPathComponent("missing") + #expect(throws: WindowsPrivateDirectoryError.self) { + try WindowsPrivateDirectory(existingDirectory: missing) + } + #expect(throws: WindowsPrivateDirectoryError.self) { + try FileSystem.validateDirectory(missing, containedIn: root) + } + #expect(try FileSystem.isAbsent(missing)) + let outside = other.appendingPathComponent("child") + #expect(throws: WindowsPrivateDirectoryError.self) { + try FileSystem.prepareParent(outside, containedIn: root) + } + #expect(try FileSystem.isAbsent(outside)) + let inherited = root.appendingPathComponent("inherited") + try FileManager.default.createDirectory(at: inherited, withIntermediateDirectories: false) + try FileSystem.validateDirectory(inherited, containedIn: root) + #expect(throws: WindowsPrivateDirectoryError.self) { + try WindowsPrivateDirectory(existingDirectory: inherited) + } + } + + @Test("Native identity resolves source aliases while managed roots reject directory reparses") + func aliasesAndReparses() throws { + let container = temporaryURL() + defer { try? FileManager.default.removeItem(at: container) } + let root = container.appendingPathComponent("root 汉字") + let protection = try FileSystem.prepareRoot(root) + defer { withExtendedLifetime(protection) {} } + let link = container.appendingPathComponent("alias") + try makeLink(link, to: root) + #expect(try FileSystem.sameDirectory(link, root)) + #expect( + try FileSystem.sameDirectory( + FileSystem.canonicalDirectory(link.path, relativeTo: container), root)) + #expect(throws: WindowsPrivateDirectoryError.self) { + try FileSystem.validateDirectory(link, containedIn: root) + } + let dangling = root.appendingPathComponent("dangling") + try makeLink(dangling, to: root.appendingPathComponent("missing")) + #expect(try !FileSystem.isAbsent(dangling)) + #expect(try !FileSystem.provisionPathIsAvailable(dangling)) + #expect(throws: WindowsPrivateDirectoryError.self) { + try FileSystem.validateDirectory(dangling, containedIn: root) + } + } + + @Test("An inaccessible path is never treated as absent") + func unverifiableAbsence() throws { + let file = temporaryURL() + defer { try? FileManager.default.removeItem(at: file) } + try Data("preserve".utf8).write(to: file) + let handle = try #require( + CreateFileW( + Array(file.path.utf16) + [0], DWORD(READ_CONTROL | WRITE_DAC), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), nil, DWORD(OPEN_EXISTING), 0, + nil)) + try #require(handle != INVALID_HANDLE_VALUE) + defer { CloseHandle(handle) } + var original: PSECURITY_DESCRIPTOR? + try #require( + GetSecurityInfo( + handle, SE_FILE_OBJECT, DWORD(DACL_SECURITY_INFORMATION), nil, nil, nil, nil, &original) + == DWORD(ERROR_SUCCESS)) + let saved = try #require(original) + defer { LocalFree(saved) } + var empty: PSECURITY_DESCRIPTOR? + try #require( + ConvertStringSecurityDescriptorToSecurityDescriptorW( + Array("D:P".utf16) + [0], DWORD(SDDL_REVISION_1), &empty, nil)) + let denied = try #require(empty) + defer { LocalFree(denied) } + try #require(SetKernelObjectSecurity(handle, DWORD(DACL_SECURITY_INFORMATION), denied)) + defer { #expect(SetKernelObjectSecurity(handle, DWORD(DACL_SECURITY_INFORMATION), saved)) } + #expect(throws: WindowsPrivateDirectoryError.self) { try FileSystem.isAbsent(file) } + } + + @discardableResult + private func git(_ arguments: [String], in directory: URL) throws -> CommandResult { + let result = try runGit(arguments, in: directory) + try #require(result.exitCode == 0, "Git failed: \(result.stderr)") + try #require(!result.timedOut) + return result + } + + private func runGit(_ arguments: [String], in directory: URL) throws -> CommandResult { + try ProcessCommandRunner().run( + executable: FileSystem.gitExecutable, arguments: arguments, workingDirectory: directory, + environment: ["GIT_TERMINAL_PROMPT": "0", "GIT_CONFIG_NOSYSTEM": "1", "LC_ALL": "C"], + timeoutMilliseconds: 30_000, maxOutputBytes: 1_048_576) + } + + private func makeLink(_ link: URL, to destination: URL) throws { + try #require( + CreateSymbolicLinkW( + Array(link.path.utf16) + [0], Array(destination.path.utf16) + [0], + DWORD(SYMBOLIC_LINK_FLAG_DIRECTORY | SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE)) != 0) + } + + private func temporaryURL() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("worktree 汉字 \(UUID())") + } +} diff --git a/Tests/WindowsSQLite/DirectoryLifetimeTests.swift b/Tests/WindowsSQLite/DirectoryLifetimeTests.swift new file mode 100644 index 0000000..2afed7b --- /dev/null +++ b/Tests/WindowsSQLite/DirectoryLifetimeTests.swift @@ -0,0 +1,73 @@ +import Foundation +import GRDB +import Testing +import WinSDK + +@Suite("Windows database directory lifetime", .timeLimit(.minutes(1))) +struct DirectoryLifetimeTests { + @Test("A native GRDB connection retains private ancestry through close and release") + func connectionOwnsDirectory() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "database 汉字 \(UUID())") + let moved = root.appendingPathExtension("moved") + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: moved) + } + let state = root.appendingPathComponent("state") + let reference = Reference() + try useAndClose(state: state, root: root, moved: moved, reference: reference) + // Dispatch retires GRDB's queue-specific context asynchronously. A cleared weak + // reference alone does not observe completion of the native handle closes. + let deadline = ContinuousClock.now + .seconds(3) + while !isReleased(reference), ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(5)) + } + try #require(reference.directory == nil) + while !MoveFileW(Array(root.path.utf16) + [0], Array(moved.path.utf16) + [0]) { + let code = GetLastError() + try #require(code == DWORD(ERROR_SHARING_VIOLATION), "Directory rename failed: \(code)") + try #require(ContinuousClock.now < deadline, "Native directory handles remain open") + try await Task.sleep(for: .milliseconds(5)) + } + let reopened = try open(state: moved.appendingPathComponent("state"), reference: reference) + let value = try await reopened.read { db in + try Int64.fetchOne(db, sql: "SELECT value FROM records") + } + #expect(value == Int64.max) + try reopened.close() + } + + private func isReleased(_ reference: Reference) -> Bool { reference.directory == nil } + + private func useAndClose(state: URL, root: URL, moved: URL, reference: Reference) throws { + let database = try open(state: state, reference: reference) + defer { withExtendedLifetime(database) {} } + try database.write { db in + try db.execute(sql: "CREATE TABLE records (value INTEGER NOT NULL)") + try db.execute(sql: "INSERT INTO records VALUES (?)", arguments: [Int64.max]) + } + #expect(reference.directory != nil) + try #require(!MoveFileW(Array(root.path.utf16) + [0], Array(moved.path.utf16) + [0])) + try database.close() + #expect(reference.directory != nil) + try #require( + !MoveFileW( + Array(state.path.utf16) + [0], Array(state.appendingPathExtension("moved").path.utf16) + [0] + )) + #expect(GetLastError() == DWORD(ERROR_SHARING_VIOLATION)) + } + + private func open(state: URL, reference: Reference) throws -> DatabaseQueue { + let directory = try WindowsPrivateDirectory(state) + reference.directory = directory + var configuration = Configuration() + configuration.prepareDatabase { [directory] _ in try directory.validate() } + return try DatabaseQueue( + path: state.appendingPathComponent("records.sqlite").path, configuration: configuration) + } + + private final class Reference { + weak var directory: WindowsPrivateDirectory? + } +} diff --git a/Tests/WindowsSQLite/GRDBTests.swift b/Tests/WindowsSQLite/GRDBTests.swift new file mode 100644 index 0000000..a14e734 --- /dev/null +++ b/Tests/WindowsSQLite/GRDBTests.swift @@ -0,0 +1,66 @@ +import Foundation +import GRDB +import Testing + +@Suite("Native SQLite Swift consumer") +struct GRDBTests { + private enum Rollback: Error, Equatable { case requested } + + @Test + func rollbackAndExactIntegersSurviveReopening() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("ownership.sqlite").path + let database = try DatabaseQueue(path: path) + defer { try? database.close() } + var migrator = DatabaseMigrator() + migrator.registerMigration("ownership") { db in + try db.execute( + sql: "CREATE TABLE owners (id INTEGER PRIMARY KEY, generation INTEGER NOT NULL)") + } + try migrator.migrate(database) + try database.write { db in + try db.execute( + sql: "INSERT INTO owners VALUES (?, ?)", arguments: [Int64.max, Int64.min]) + } + #expect(throws: Rollback.requested) { + try database.write { db in + try db.execute(sql: "UPDATE owners SET generation = 0") + throw Rollback.requested + } + } + try database.close() + let reopened = try DatabaseQueue(path: path) + defer { try? reopened.close() } + try reopened.read { db in + let count = try Int.fetchOne(db, sql: "SELECT count(*) FROM owners") + let id = try Int64.fetchOne(db, sql: "SELECT id FROM owners") + let generation = try Int64.fetchOne(db, sql: "SELECT generation FROM owners") + #expect(count == 1) + #expect(id == Int64.max) + #expect(generation == Int64.min) + } + } + + @Test + func snapshotPoolRetainsItsViewAcrossACommittedWrite() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try DatabasePool(path: directory.appendingPathComponent("snapshots.sqlite").path) + defer { try? database.close() } + try database.write { db in + try db.execute(sql: "CREATE VIRTUAL TABLE search USING fts5(body)") + try db.execute(sql: "INSERT INTO search VALUES ('owned runtime')") + } + let snapshot = try database.makeSnapshotPool() + defer { try? snapshot.close() } + try database.write { db in + try db.execute(sql: "INSERT INTO search VALUES ('replacement runtime')") + } + let query = "SELECT count(*) FROM search WHERE search MATCH 'runtime'" + #expect(try snapshot.read { try Int.fetchOne($0, sql: query) } == 1) + #expect(try database.read { try Int.fetchOne($0, sql: query) } == 2) + } +} diff --git a/Tests/WindowsSQLite/Package.swift.template b/Tests/WindowsSQLite/Package.swift.template new file mode 100644 index 0000000..64331a7 --- /dev/null +++ b/Tests/WindowsSQLite/Package.swift.template @@ -0,0 +1,12 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "WindowsDatabaseConsumer", + platforms: [.macOS(.v14)], + dependencies: [.package(path: "__GRDB_PATH__")], + targets: [ + .testTarget( + name: "DatabaseTests", + dependencies: [.product(name: "GRDB", package: "GRDB.swift")]) + ]) diff --git a/Tests/WindowsSQLite/verify.c b/Tests/WindowsSQLite/verify.c new file mode 100644 index 0000000..94f81aa --- /dev/null +++ b/Tests/WindowsSQLite/verify.c @@ -0,0 +1,77 @@ +#include +#include +#include +#include + +#define REQUIRE(condition) do { \ + if (!(condition)) { \ + fprintf(stderr, "SQLite verification failed at line %d: %s\n", __LINE__, #condition); \ + return 1; \ + } \ +} while (0) + +static void execute(sqlite3 *database, const char *sql) { + char *message = NULL; + if (sqlite3_exec(database, sql, NULL, NULL, &message) != SQLITE_OK) { + fprintf(stderr, "SQLite SQL verification failed: %s\n", message); + sqlite3_free(message); + exit(1); + } +} + +static int scalar(sqlite3 *database, const char *sql) { + sqlite3_stmt *statement = NULL; + if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) != SQLITE_OK || + sqlite3_step(statement) != SQLITE_ROW) { + fprintf(stderr, "SQLite scalar verification failed: %s\n", sqlite3_errmsg(database)); + exit(1); + } + int result = sqlite3_column_int(statement, 0); + if (sqlite3_finalize(statement) != SQLITE_OK) exit(1); + return result; +} + +int main(int argc, char **argv) { + REQUIRE(argc == 3); + REQUIRE(strcmp(sqlite3_libversion(), argv[2]) == 0); + REQUIRE(sqlite3_threadsafe() == 1); + REQUIRE(sqlite3_compileoption_used("ENABLE_FTS5")); + REQUIRE(sqlite3_compileoption_used("ENABLE_SNAPSHOT")); + REQUIRE(sqlite3_compileoption_used("ENABLE_COLUMN_METADATA")); + + sqlite3 *reader = NULL; + sqlite3 *writer = NULL; + REQUIRE(sqlite3_open(argv[1], &reader) == SQLITE_OK); + execute(reader, "PRAGMA journal_mode=WAL; CREATE TABLE entries(id INTEGER PRIMARY KEY);" + "INSERT INTO entries VALUES (1);"); + REQUIRE(sqlite3_open(argv[1], &writer) == SQLITE_OK); + + execute(reader, "BEGIN;"); + REQUIRE(scalar(reader, "SELECT count(*) FROM entries;") == 1); + sqlite3_snapshot *snapshot = NULL; + REQUIRE(sqlite3_snapshot_get(reader, "main", &snapshot) == SQLITE_OK); + execute(writer, "INSERT INTO entries VALUES (2);"); + execute(reader, "COMMIT; BEGIN;"); + REQUIRE(sqlite3_snapshot_open(reader, "main", snapshot) == SQLITE_OK); + REQUIRE(scalar(reader, "SELECT count(*) FROM entries;") == 1); + execute(reader, "COMMIT;"); + sqlite3_snapshot_free(snapshot); + REQUIRE(scalar(reader, "SELECT count(*) FROM entries;") == 2); + + execute(writer, "BEGIN; INSERT INTO entries VALUES (3); ROLLBACK;"); + REQUIRE(scalar(reader, "SELECT count(*) FROM entries;") == 2); + execute(writer, "CREATE VIRTUAL TABLE search USING fts5(body);" + "INSERT INTO search VALUES ('owned runtime');"); + REQUIRE(scalar(reader, "SELECT count(*) FROM search WHERE search MATCH 'runtime';") == 1); + + const char *type = NULL; + REQUIRE(sqlite3_table_column_metadata(reader, "main", "entries", "id", &type, + NULL, NULL, NULL, NULL) == SQLITE_OK); + REQUIRE(type != NULL && strcmp(type, "INTEGER") == 0); + REQUIRE(sqlite3_close(writer) == SQLITE_OK); + REQUIRE(sqlite3_close(reader) == SQLITE_OK); + REQUIRE(sqlite3_shutdown() == SQLITE_OK); + printf("{\"version\":\"%s\",\"serialized\":true,\"walSnapshot\":true," + "\"rollback\":true,\"fts5\":true,\"columnMetadata\":true}\n", sqlite3_libversion()); + return 0; +} diff --git a/Tests/schema-import.test.mjs b/Tests/schema-import.test.mjs new file mode 100644 index 0000000..c8b0f8d --- /dev/null +++ b/Tests/schema-import.test.mjs @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readLockedSnapshot, syncArtifacts } from '../Scripts/import-schema.mjs'; + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'codex-schema-authority-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const sdk = join(root, 'sdk'); + mkdirSync(sdk); + const git = (...args) => execFileSync('git', ['-C', sdk, ...args], { encoding: 'utf8' }).trim(); + git('init', '-q'); + const schemaRoot = join(sdk, 'Vendor/CodexAppServerProtocolSchema'); + const write = (name, value) => { + const path = join(schemaRoot, name); + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, JSON.stringify(value) + '\n'); + }; + write('upstream.lock.json', { upstream: { tag: 'rust-v1.2.3', commit: 'a'.repeat(40) } }); + write('method-adoption.json', { + schema: 'swift-codex.codex-app-server-method-adoption.v1', upstreamTag: 'rust-v1.2.3', + adopted: { stable: ['thread/read'], experimental: [] }, excluded: [], + }); + for (const channel of ['stable', 'experimental']) { + for (const direction of ['ClientRequest', 'ClientNotification', 'ServerRequest', 'ServerNotification']) { + write(`${channel}/json/${direction}.json`, { + oneOf: [{ required: ['method'], properties: { method: { enum: ['thread/read'] } } }], + }); + } + } + git('add', '.'); + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'Fixture SDK authority'); + const revision = git('rev-parse', 'HEAD'); + const pin = { identity: 'swift-codex', location: 'https://github.com/swift-library/swift-codex.git', state: { version: '0.2.2', revision } }; + writeFileSync(join(root, 'Package.resolved'), JSON.stringify({ pins: [pin] })); + return { root, sdk, revision, write, git, pin }; +} + +test('derives declarations and adoption from the locked commit despite dirty SDK work', t => { + const f = fixture(t); + f.write('method-adoption.json', { unrelated: 'uncommitted work must be preserved' }); + f.write('stable/json/ClientRequest.json', { unrelated: 'dirty schema' }); + const snapshot = readLockedSnapshot(f.root, f.sdk); + assert.equal(snapshot.receipt.sdk.revision, f.revision); + assert.equal(snapshot.receipt.codexVersion, '1.2.3'); + assert.deepEqual(JSON.parse(snapshot.artifacts.get('adoption.json')).adopted.stable, ['thread/read']); + assert.match(readFileSync(join(f.sdk, 'Vendor/CodexAppServerProtocolSchema/method-adoption.json'), 'utf8'), /uncommitted/); +}); + +test('check detects drift and never rewrites output', t => { + const f = fixture(t); + const snapshot = readLockedSnapshot(f.root, f.sdk); + const output = join(f.root, 'output'); + syncArtifacts(snapshot, output, false); + syncArtifacts(snapshot, output, true); + const path = join(output, 'stable/ClientRequest.json'); + writeFileSync(path, 'tampered'); + assert.throws(() => syncArtifacts(snapshot, output, true), /drift/); + assert.equal(readFileSync(path, 'utf8'), 'tampered'); +}); + +test('missing locked commit never falls back to a moving checkout', t => { + const f = fixture(t); + f.pin.state.revision = 'f'.repeat(40); + writeFileSync(join(f.root, 'Package.resolved'), JSON.stringify({ pins: [f.pin] })); + assert.throws(() => readLockedSnapshot(f.root, f.sdk), /locked SDK/); +}); + +test('invalid adoption input cannot produce a partial output snapshot', t => { + const f = fixture(t); + f.write('method-adoption.json', { schema: 'wrong', adopted: { stable: ['missing'] } }); + f.git('add', '.'); + f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'Invalid fixture authority'); + f.pin.state.revision = f.git('rev-parse', 'HEAD'); + writeFileSync(join(f.root, 'Package.resolved'), JSON.stringify({ pins: [f.pin] })); + assert.throws(() => readLockedSnapshot(f.root, f.sdk), /adoption/); +}); diff --git a/Tests/test_package.py b/Tests/test_package.py index 8f3efb9..38095f3 100644 --- a/Tests/test_package.py +++ b/Tests/test_package.py @@ -17,6 +17,12 @@ class PackageOwnershipTests(unittest.TestCase): + def setUp(self): + # Build commands are fixture responses; publication still uses the native OS. + native_build = patch.object(package, "current_platform", return_value="macos") + native_build.start() + self.addCleanup(native_build.stop) + def make_repository(self, root): repo = root / "repo" built = repo / ".build/debug" @@ -27,7 +33,8 @@ def make_repository(self, root): for name in ("computer-mcp-plugin.toml", "README.md", "CONTRIBUTING.md", "LICENSE", "THIRD_PARTY_NOTICES.md"): (repo / name).write_text("fixture content\n") (repo / "computer-mcp-plugin.toml").write_text( - "id = 'codex'\nversion = '1.2.3'\n\n[compatibility]\narchitectures = ['arm64']\n" + "id = 'codex'\nversion = '1.2.3'\n[[mcp]]\nid = 'app-server'\n" + "executable = { path = 'bin/codex-mcp-adapter' }\n\n[compatibility]\narchitectures = ['arm64']\n" ) metadata = repo / version.GENERATED metadata.parent.mkdir(parents=True) @@ -97,6 +104,39 @@ def test_architecture_declaration_is_explicit_and_matches_all_slices(self): manifest.write_text("[compatibility]\narchitectures = ['x86_64', 'arm64']") package.validate_architectures(manifest, ["arm64", "x86_64"]) + def test_named_archives_select_exact_platform_and_architecture_without_manifest_rewriting(self): + manifest = Path(__file__).resolve().parents[1] / "computer-mcp-plugin.toml" + original = manifest.read_bytes() + self.assertEqual(package.validate_architectures(manifest, ["arm64"], "macos"), + "codex-plugin-macos-arm64.zip") + self.assertEqual(package.validate_architectures(manifest, ["x86_64"], "windows"), + "codex-plugin-windows-x86_64.zip") + for platform, slices in [("windows", ["arm64"]), ("macos", ["x86_64"]), + ("macos", ["arm64", "x86_64"]), ("linux", ["x86_64"])]: + with self.subTest(platform=platform, slices=slices), self.assertRaises(ValueError): + package.validate_architectures(manifest, slices, platform) + self.assertEqual(manifest.read_bytes(), original) + + @unittest.skipUnless(os.name == "nt", "Native Windows directory reparse boundary") + def test_windows_junction_is_rejected_before_copying_external_content(self): + import subprocess + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + outside, source = root / "outside", root / "source" + outside.mkdir() + source.mkdir() + (outside / "sentinel").write_bytes(b"user owned") + junction = source / "junction" + subprocess.run(["cmd", "/c", "mklink", "/J", str(junction), str(outside)], + check=True, capture_output=True) + try: + with self.assertRaisesRegex(ValueError, "reparse"): + package.copy_tree(source, root / "payload") + self.assertFalse((root / "payload").exists()) + self.assertEqual((outside / "sentinel").read_bytes(), b"user owned") + finally: + junction.rmdir() + def test_package_rejects_slice_mismatch_and_manifest_mutation_before_publication(self): for failure in ("slice_mismatch", "manifest_mutation", "version_mismatch"): with self.subTest(failure=failure), tempfile.TemporaryDirectory() as directory: @@ -182,7 +222,8 @@ def test_regular_files_preserve_bytes_and_executable_mode(self): target = root / "target" package.copy_file(source, target) self.assertEqual(target.read_bytes(), source.read_bytes()) - self.assertEqual(target.stat().st_mode & 0o777, 0o755) + if os.name != "nt": + self.assertEqual(target.stat().st_mode & 0o777, 0o755) package.validate_payload(root) def test_file_links_and_special_files_are_rejected_without_reading_their_targets(self): @@ -192,9 +233,12 @@ def test_file_links_and_special_files_are_rejected_without_reading_their_targets outside.write_bytes(b"user owned") link = root / "link" link.symlink_to(outside) - fifo = root / "fifo" - os.mkfifo(fifo) - for source in (link, fifo): + sources = [link] + if hasattr(os, "mkfifo"): + fifo = root / "fifo" + os.mkfifo(fifo) + sources.append(fifo) + for source in sources: with self.subTest(source=source.name), self.assertRaises(ValueError): package.copy_file(source, root / "target") self.assertFalse((root / "target").exists()) @@ -213,10 +257,9 @@ def test_nested_file_directory_and_dangling_links_are_not_dereferenced(self): link = source / name link.symlink_to(target) copied = root / ("copied-" + name) - package.copy_tree(source, copied) - self.assertTrue((copied / name).is_symlink()) with self.assertRaises(ValueError): - package.validate_payload(copied) + package.copy_tree(source, copied) + self.assertFalse(copied.exists()) link.unlink() self.assertEqual((outside / "sentinel").read_bytes(), b"user owned") diff --git a/Tests/test_package_archive.py b/Tests/test_package_archive.py new file mode 100644 index 0000000..8425bda --- /dev/null +++ b/Tests/test_package_archive.py @@ -0,0 +1,114 @@ +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import stat +import sys +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +scripts = Path(__file__).resolve().parents[1] / "Scripts" +sys.path.insert(0, str(scripts)) +spec = importlib.util.spec_from_file_location("check_package", scripts / "check-package.py") +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class ArchiveAcceptanceTests(unittest.TestCase): + def fixture(self, root, additions=()): + manifest = root / "manifest.toml" + manifest.write_bytes(b"id = 'codex'\nversion = '1.2.3'\n[compatibility]\narchitectures = ['arm64']\n") + archive = root / "codex-plugin.zip" + rows = [("computer-mcp-plugin.toml", manifest.read_bytes(), stat.S_IFREG | 0o644), + ("bin/adapter", b"executable bytes", stat.S_IFREG | 0o755), *additions] + inventory = {} + with zipfile.ZipFile(archive, "w") as zipped: + for name, data, mode in rows: + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.external_attr = mode << 16 + zipped.writestr(info, data) + if not name.endswith("/"): + inventory[name] = hashlib.sha256(data).hexdigest() + receipt = root / "receipt.json" + receipt.write_text(json.dumps({"files": inventory, "plugin_id": "codex", "platform": "macos", + "architectures": ["arm64"], "archive": archive.name, "archive_bytes": archive.stat().st_size, + "archive_sha256": hashlib.sha256(archive.read_bytes()).hexdigest()})) + return archive, receipt, manifest + + def test_exact_archive_relocates_with_manifest_bytes_and_executable_mode(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + arguments = self.fixture(root) + result = checker.verify(*arguments, root / "relocated") + self.assertEqual(result["files_verified"], 2) + self.assertEqual((root / "relocated/computer-mcp-plugin.toml").read_bytes(), arguments[2].read_bytes()) + self.assertEqual((root / "relocated/bin/adapter").read_bytes(), b"executable bytes") + if os.name != "nt": + self.assertEqual((root / "relocated/bin/adapter").stat().st_mode & 0o777, 0o755) + + def test_unsafe_names_links_modes_and_case_aliases_fail_before_extraction(self): + cases = [(name, b"content", stat.S_IFREG | 0o644) for name in ( + "../outside", "/absolute", "C:/outside", "bin\\outside", "bin/../outside", + "bin/./outside", "bin/file.", "bin/file ", "bin/NUL", "BIN/other")] + cases += [("bin/link", b"../outside", stat.S_IFLNK | 0o777), + ("bin/privileged", b"content", stat.S_IFREG | 0o4755), + ("bin/pipe", b"", stat.S_IFIFO | 0o644), + ("BIN/ADAPTER", b"collision", stat.S_IFREG | 0o644), + ("bin", b"file/directory collision", stat.S_IFREG | 0o644), + ("unknown/", b"", stat.S_IFDIR | 0o755)] + for row in cases: + with self.subTest(row=row), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + arguments = self.fixture(root, [row]) + with self.assertRaises(ValueError): + checker.verify(*arguments, root / "relocated") + self.assertFalse((root / "relocated").exists()) + + def test_archive_inventory_and_manifest_tampering_are_detected(self): + for case in ["archive", "file", "omitted", "manifest"]: + with self.subTest(case=case), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive, receipt, manifest = self.fixture(root) + if case == "archive": + with archive.open("ab") as stream: + stream.write(b"unaccepted bytes") + elif case == "manifest": + with manifest.open("ab") as stream: + stream.write(b"# different declaration\n") + else: + data = json.loads(receipt.read_text()) + if case == "file": + data["files"]["bin/adapter"] = "0" * 64 + else: + del data["files"]["bin/adapter"] + receipt.write_text(json.dumps(data)) + with self.assertRaises(ValueError): + checker.verify(archive, receipt, manifest, root / "relocated") + self.assertFalse((root / "relocated").exists()) + + def test_relocation_preserves_destination_created_during_verification(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + arguments = self.fixture(root) + destination = root / "relocated" + original = checker.publish_directory + + def publish(stage, output): + output.mkdir() + (output / "sentinel").write_bytes(b"user owned") + original(stage, output) + + with patch.object(checker, "publish_directory", side_effect=publish), self.assertRaises(FileExistsError): + checker.verify(*arguments, destination) + self.assertEqual((destination / "sentinel").read_bytes(), b"user owned") + self.assertEqual(list(root.glob("codex-relocation-*")), []) + with self.assertRaises(ValueError): + checker.verify(*arguments, destination) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tests/test_protocol_checkout.py b/Tests/test_protocol_checkout.py new file mode 100644 index 0000000..828ce4e --- /dev/null +++ b/Tests/test_protocol_checkout.py @@ -0,0 +1,37 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +class ProtocolCheckoutTests(unittest.TestCase): + def test_windows_checkout_preserves_hash_bound_resource_bytes(self): + repository = Path(__file__).resolve().parents[1] + relatives = [Path('Sources/CodexAdapter/Resources/Protocol'), Path('Vendor/SwiftWindowsRuntime')] + originals = {path.relative_to(repository): path.read_bytes() + for relative in relatives for path in (repository / relative).rglob('*') if path.is_file()} + manifest = Path('computer-mcp-plugin.toml') + originals[manifest] = (repository / manifest).read_bytes() + self.assertTrue(originals) + with tempfile.TemporaryDirectory(prefix='protocol-checkout-') as directory: + root = Path(directory) + index_root = root / 'index' + checkout = root / 'checkout' + index_root.mkdir() + checkout.mkdir() + shutil.copy2(repository / '.gitattributes', index_root / '.gitattributes') + for path, data in originals.items(): + target = index_root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + def git(*arguments): + subprocess.run(['git', '-c', 'core.autocrlf=true', *arguments], cwd=index_root, + check=True, capture_output=True) + git('init', '--quiet') + git('add', '--', '.gitattributes', manifest.as_posix(), *(relative.as_posix() for relative in relatives)) + git('checkout-index', '--all', '--prefix=' + str(checkout) + os.sep) + for path, expected in originals.items(): + with self.subTest(resource=path.as_posix()): + self.assertEqual((checkout / path).read_bytes(), expected) diff --git a/Tests/test_protocol_cleanup.py b/Tests/test_protocol_cleanup.py new file mode 100644 index 0000000..4341592 --- /dev/null +++ b/Tests/test_protocol_cleanup.py @@ -0,0 +1,59 @@ +import importlib.util +import os +from pathlib import Path +import stat +import tempfile +import unittest +from unittest.mock import patch + + +spec = importlib.util.spec_from_file_location( + "native_protocol_check", Path(__file__).parent / "WindowsAdapter/ProtocolCheck.py") +protocol = importlib.util.module_from_spec(spec) +spec.loader.exec_module(protocol) + + +class ProtocolCleanupTests(unittest.TestCase): + def test_read_only_git_objects_are_removed_from_the_private_home(self): + with tempfile.TemporaryDirectory() as parent: + root = Path(parent) / "private" + objects = root / "codex/.tmp/plugins-clone/.git/objects/pack" + objects.mkdir(parents=True) + index = objects / "pack-fixture.idx" + index.write_bytes(b"private fixture") + index.chmod(stat.S_IREAD) + protocol.remove_private_state(root) + self.assertFalse(root.exists()) + + def test_windows_read_only_failure_retries_only_the_owned_regular_file(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + index = root / "pack-fixture.idx" + index.write_bytes(b"private fixture") + index.chmod(stat.S_IREAD) + + def failed_unlink(path, onerror): + self.assertEqual(path, root) + error = PermissionError("Windows read-only object") + onerror(os.unlink, str(index), (PermissionError, error, None)) + + with patch.object(protocol.shutil, "rmtree", side_effect=failed_unlink): + self.assertEqual(protocol.remove_private_state(root), 1) + self.assertFalse(index.exists()) + + def test_an_acl_denial_is_not_reported_as_success_or_made_more_permissive(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + file = root / "writable" + file.write_bytes(b"retained evidence") + mode = file.stat().st_mode + error = PermissionError("ACL denies cleanup") + + def denied(path, onerror): + onerror(os.unlink, str(file), (PermissionError, error, None)) + + with patch.object(protocol.shutil, "rmtree", side_effect=denied): + with self.assertRaises(PermissionError): + protocol.remove_private_state(root) + self.assertEqual(file.stat().st_mode, mode) + self.assertEqual(file.read_bytes(), b"retained evidence") diff --git a/Tests/test_windows_package.py b/Tests/test_windows_package.py new file mode 100644 index 0000000..16c9014 --- /dev/null +++ b/Tests/test_windows_package.py @@ -0,0 +1,30 @@ +import importlib.util +import json +from pathlib import Path +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "Scripts")) +import windows_package + + +class WindowsPackageNoticesTests(unittest.TestCase): + def test_notice_bytes_and_component_coverage_bind_to_the_owned_sources(self): + source = Path(__file__).resolve().parents[1] / "Vendor/SwiftWindowsRuntime" + original = windows_package.notice_sources(source) + self.assertEqual(len(original["runtime_libraries"]), 19) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "notices" + shutil.copytree(source, root) + selected = root / original["sources"][0]["file"] + selected.write_bytes(selected.read_bytes() + b"modified") + with self.assertRaisesRegex(ValueError, "differs from its upstream"): + windows_package.notice_sources(root) + shutil.copyfile(source / original["sources"][0]["file"], selected) + changed = json.loads((root / "sources.json").read_text()) + changed["runtime_libraries"]["swiftcore.dll"]["sources"] = ["missing/LICENSE"] + (root / "sources.json").write_text(json.dumps(changed)) + with self.assertRaisesRegex(ValueError, "incomplete notice"): + windows_package.notice_sources(root) diff --git a/Tests/test_windows_runtime.py b/Tests/test_windows_runtime.py new file mode 100644 index 0000000..dccc2b3 --- /dev/null +++ b/Tests/test_windows_runtime.py @@ -0,0 +1,97 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + + +spec = importlib.util.spec_from_file_location( + "windows_runtime", Path(__file__).resolve().parents[1] / "Scripts/windows_runtime.py") +runtime = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runtime) + + +class WindowsRuntimeTests(unittest.TestCase): + def test_recursive_shared_and_cyclic_imports_preserve_all_owners(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + libraries, system = root / "runtime", root / "system" + libraries.mkdir() + system.mkdir() + executable = root / "adapter.exe" + executable.write_bytes(b"adapter") + for name in ("swiftCore.dll", "Foundation.dll"): + (libraries / name).write_bytes(name.encode()) + (system / "KERNEL32.dll").write_bytes(b"os") + (system / "VCRUNTIME140.dll").write_bytes(b"msvc") + graph = { + "adapter.exe": ["Foundation.dll", "swiftCore.dll"], + "Foundation.dll": ["swiftCore.dll", "VCRUNTIME140.dll"], + "swiftCore.dll": ["Foundation.dll", "KERNEL32.dll"], + "VCRUNTIME140.dll": ["api-ms-win-crt-runtime-l1-1-0.dll"], + } + with patch.object(runtime, "imports", side_effect=lambda path, _: ("x86_64", graph[path.name])): + report = runtime.audit(executable, [libraries], system, Path("inspector")) + rows = {row["name"]: row for row in report["libraries"]} + self.assertEqual(len(rows), 5) + self.assertEqual(set(rows["swiftCore.dll"]["imported_by"]), {"adapter.exe", "Foundation.dll"}) + self.assertEqual(rows["VCRUNTIME140.dll"]["role"], "external-msvc-runtime") + self.assertEqual(rows["KERNEL32.dll"]["role"], "windows-system") + self.assertFalse(report["relocation_verified"]) + + def test_conflicting_runtime_versions_and_missing_imports_fail_closed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + first, second = root / "first", root / "second" + first.write_bytes(b"one") + second.write_bytes(b"two") + with self.assertRaisesRegex(ValueError, "Conflicting"): + runtime.resolve("swiftCore.dll", [{"swiftcore.dll": first}, {"swiftcore.dll": second}], {}) + with self.assertRaisesRegex(ValueError, "Unresolved"): + runtime.resolve("missing.dll", [], {}) + + def test_case_collisions_and_linked_library_sources_fail_closed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target = root / "outside" + target.write_bytes(b"outside") + link = root / "library.dll" + link.symlink_to(target) + with self.assertRaisesRegex(ValueError, "link or special"): + runtime.resolve("library.dll", [{"library.dll": link}], {}) + with patch.object(Path, "iterdir", return_value=iter([root / "A.dll", root / "a.dll"])): + with self.assertRaisesRegex(ValueError, "Case-colliding"): + runtime.directory_files(root) + + def test_runtime_architecture_must_match_executable(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + system = root / "system" + system.mkdir() + executable, library = root / "adapter.exe", root / "swiftCore.dll" + executable.write_bytes(b"adapter") + library.write_bytes(b"library") + with patch.object(runtime, "imports", side_effect=[("x86_64", [library.name]), ("aarch64", [])]): + with self.assertRaisesRegex(ValueError, "architecture mismatch"): + runtime.audit(executable, [root], system, Path("inspector")) + + def test_live_modules_must_use_packaged_libraries_and_only_system_fallback(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + local, toolchain, system = [root / name for name in ("package", "toolchain", "system")] + for path in (local, toolchain, system): + path.mkdir() + executable, library = local / "adapter.exe", local / "swiftCore.dll" + external, native = toolchain / "swiftCore.dll", system / "KERNEL32.dll" + for path in (executable, library, external, native): + path.write_bytes(path.name.encode()) + rows = runtime.verify_app_local_modules([executable, library, native], executable, system) + self.assertEqual([row["role"] for row in rows], ["adapter", "app-local-runtime", "windows-system"]) + with self.assertRaisesRegex(ValueError, "outside the package"): + runtime.verify_app_local_modules([executable, external], executable, system) + unowned = toolchain / "unowned.dll" + unowned.write_bytes(b"unowned") + with self.assertRaisesRegex(ValueError, "undeclared external"): + runtime.verify_app_local_modules([library, unowned], executable, system) + with self.assertRaisesRegex(ValueError, "No app-local"): + runtime.verify_app_local_modules([executable, native], executable, system) diff --git a/Vendor/SwiftWindowsRuntime/README.md b/Vendor/SwiftWindowsRuntime/README.md new file mode 100644 index 0000000..9973909 --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/README.md @@ -0,0 +1,28 @@ +# Swift Windows runtime notices + +This directory preserves original notices for the open-source components in the +Swift Windows runtime used by the native adapter build. `sources.json` binds each +file to its source repository, release tag, exact commit, original path, length +and SHA256. The runtime files selected for an archive have their own native +import and byte inventory; the notice source list is not a binary inventory. + +The notice set covers Swift, libdispatch, Foundation, FoundationICU and the Swift +string-processing library. Swift's Windows build also links curl and zlib into +FoundationNetworking. Their original notices are included alongside the +Foundation third-party NOTICE and ICU's original third-party notices. + +FoundationICU identifies ICU74.1 in its vendored version header and describes +extraction from Apple OSS ICU. The supplemental Apple ICU notice records its own +source tag; it does not identify an exact Apple extraction revision for the +FoundationICU sources. Preserve these original texts together with the Foundation +wrapper license. + +These files cover the listed open-source components. Microsoft Visual C++ runtime +files have separate Microsoft license and redistribution terms. Their presence +in a Swift toolchain does not make them subject to Swift's license. + +Source references: + +- [Swift Windows build](https://github.com/swiftlang/swift/blob/swift-6.2.3-RELEASE/utils/build.ps1) +- [FoundationICU source and version](https://github.com/swiftlang/swift-foundation-icu/tree/swift-6.2.3-RELEASE) +- [Microsoft runtime redistribution](https://learn.microsoft.com/en-us/cpp/windows/redistributing-visual-cpp-files?view=msvc-170) diff --git a/Vendor/SwiftWindowsRuntime/apple-icu/LICENSE b/Vendor/SwiftWindowsRuntime/apple-icu/LICENSE new file mode 100644 index 0000000..fc3fd7c --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/apple-icu/LICENSE @@ -0,0 +1,447 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2016-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +---------------------------------------------------------------------- + +Third-Party Software Licenses + +This section contains third-party software notices and/or additional +terms for licensed third-party software components included within ICU +libraries. + +---------------------------------------------------------------------- + +ICU License - ICU 1.8.1 to ICU 57.1 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1995-2016 International Business Machines Corporation and others +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, and/or sell copies of the Software, and to permit persons +to whom the Software is furnished to do so, provided that the above +copyright notice(s) and this permission notice appear in all copies of +the Software and that both the above copyright notice(s) and this +permission notice appear in supporting documentation. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY +SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, use +or other dealings in this Software without prior written authorization +of the copyright holder. + +All trademarks and registered trademarks mentioned herein are the +property of their respective owners. + +---------------------------------------------------------------------- + +Chinese/Japanese Word Break Dictionary Data (cjdict.txt) + + # The Google Chrome software developed by Google is licensed under + # the BSD license. Other software included in this distribution is + # provided under other licenses, as set forth below. + # + # The BSD License + # http://opensource.org/licenses/bsd-license.php + # Copyright (C) 2006-2008, Google Inc. + # + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, + # this list of conditions and the following disclaimer. + # Redistributions in binary form must reproduce the above + # copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided with + # the distribution. + # Neither the name of Google Inc. nor the names of its + # contributors may be used to endorse or promote products derived from + # this software without specific prior written permission. + # + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + # + # + # The word list in cjdict.txt are generated by combining three word lists + # listed below with further processing for compound word breaking. The + # frequency is generated with an iterative training against Google web + # corpora. + # + # * Libtabe (Chinese) + # - https://sourceforge.net/project/?group_id=1519 + # - Its license terms and conditions are shown below. + # + # * IPADIC (Japanese) + # - http://chasen.aist-nara.ac.jp/chasen/distribution.html + # - Its license terms and conditions are shown below. + # + # ---------COPYING.libtabe ---- BEGIN-------------------- + # + # /* + # * Copyright (c) 1999 TaBE Project. + # * Copyright (c) 1999 Pai-Hsiang Hsiao. + # * All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the TaBE Project nor the names of its + # * contributors may be used to endorse or promote products derived + # * from this software without specific prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # /* + # * Copyright (c) 1999 Computer Systems and Communication Lab, + # * Institute of Information Science, Academia + # * Sinica. All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the Computer Systems and Communication Lab + # * nor the names of its contributors may be used to endorse or + # * promote products derived from this software without specific + # * prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # Copyright 1996 Chih-Hao Tsai @ Beckman Institute, + # University of Illinois + # c-tsai4@uiuc.edu http://casper.beckman.uiuc.edu/~c-tsai4 + # + # ---------------COPYING.libtabe-----END-------------------------------- + # + # + # ---------------COPYING.ipadic-----BEGIN------------------------------- + # + # Copyright 2000, 2001, 2002, 2003 Nara Institute of Science + # and Technology. All Rights Reserved. + # + # Use, reproduction, and distribution of this software is permitted. + # Any copy of this software, whether in its original form or modified, + # must include both the above copyright notice and the following + # paragraphs. + # + # Nara Institute of Science and Technology (NAIST), + # the copyright holders, disclaims all warranties with regard to this + # software, including all implied warranties of merchantability and + # fitness, in no event shall NAIST be liable for + # any special, indirect or consequential damages or any damages + # whatsoever resulting from loss of use, data or profits, whether in an + # action of contract, negligence or other tortuous action, arising out + # of or in connection with the use or performance of this software. + # + # A large portion of the dictionary entries + # originate from ICOT Free Software. The following conditions for ICOT + # Free Software applies to the current dictionary as well. + # + # Each User may also freely distribute the Program, whether in its + # original form or modified, to any third party or parties, PROVIDED + # that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear + # on, or be attached to, the Program, which is distributed substantially + # in the same form as set out herein and that such intended + # distribution, if actually made, will neither violate or otherwise + # contravene any of the laws and regulations of the countries having + # jurisdiction over the User or the intended distribution itself. + # + # NO WARRANTY + # + # The program was produced on an experimental basis in the course of the + # research and development conducted during the project and is provided + # to users as so produced on an experimental basis. Accordingly, the + # program is provided without any warranty whatsoever, whether express, + # implied, statutory or otherwise. The term "warranty" used herein + # includes, but is not limited to, any warranty of the quality, + # performance, merchantability and fitness for a particular purpose of + # the program and the nonexistence of any infringement or violation of + # any right of any third party. + # + # Each user of the program will agree and understand, and be deemed to + # have agreed and understood, that there is no warranty whatsoever for + # the program and, accordingly, the entire risk arising from or + # otherwise connected with the program is assumed by the user. + # + # Therefore, neither ICOT, the copyright holder, or any other + # organization that participated in or was otherwise related to the + # development of the program and their respective officials, directors, + # officers and other employees shall be held liable for any and all + # damages, including, without limitation, general, special, incidental + # and consequential damages, arising out of or otherwise in connection + # with the use or inability to use the program or any product, material + # or result produced or otherwise obtained by using the program, + # regardless of whether they have been advised of, or otherwise had + # knowledge of, the possibility of such damages at any time during the + # project or thereafter. Each user will be deemed to have agreed to the + # foregoing by his or her commencement of use of the program. The term + # "use" as used herein includes, but is not limited to, the use, + # modification, copying and distribution of the program and the + # production of secondary products from the program. + # + # In the case where the program, whether in its original form or + # modified, was distributed or delivered to or received by a user from + # any person, organization or entity other than ICOT, unless it makes or + # grants independently of ICOT any specific warranty to the user in + # writing, such person, organization or entity, will also be exempted + # from and not be held liable to the user for any such damages as noted + # above as far as the program is concerned. + # + # ---------------COPYING.ipadic-----END---------------------------------- + +---------------------------------------------------------------------- + +Lao Word Break Dictionary Data (laodict.txt) + + # Copyright (C) 2016 and later: Unicode, Inc. and others. + # License & terms of use: http://www.unicode.org/copyright.html + # Copyright (c) 2015 International Business Machines Corporation + # and others. All Rights Reserved. + # + # Project: https://github.com/rober42539/lao-dictionary + # Dictionary: https://github.com/rober42539/lao-dictionary/laodict.txt + # License: https://github.com/rober42539/lao-dictionary/LICENSE.txt + # (copied below) + # + # This file is derived from the above dictionary version of Nov 22, 2020 + # ---------------------------------------------------------------------- + # Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell. + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, this + # list of conditions and the following disclaimer. Redistributions in binary + # form must reproduce the above copyright notice, this list of conditions and + # the following disclaimer in the documentation and/or other materials + # provided with the distribution. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # OF THE POSSIBILITY OF SUCH DAMAGE. + # -------------------------------------------------------------------------- + +---------------------------------------------------------------------- + +Burmese Word Break Dictionary Data (burmesedict.txt) + + # Copyright (c) 2014 International Business Machines Corporation + # and others. All Rights Reserved. + # + # This list is part of a project hosted at: + # github.com/kanyawtech/myanmar-karen-word-lists + # + # -------------------------------------------------------------------------- + # Copyright (c) 2013, LeRoy Benjamin Sharon + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions + # are met: Redistributions of source code must retain the above + # copyright notice, this list of conditions and the following + # disclaimer. Redistributions in binary form must reproduce the + # above copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided + # with the distribution. + # + # Neither the name Myanmar Karen Word Lists, nor the names of its + # contributors may be used to endorse or promote products derived + # from this software without specific prior written permission. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS + # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF + # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + # SUCH DAMAGE. + # -------------------------------------------------------------------------- + +---------------------------------------------------------------------- + +Time Zone Database + + ICU uses the public domain data and code derived from Time Zone +Database for its time zone support. The ownership of the TZ database +is explained in BCP 175: Procedure for Maintaining the Time Zone +Database section 7. + + # 7. Database Ownership + # + # The TZ database itself is not an IETF Contribution or an IETF + # document. Rather it is a pre-existing and regularly updated work + # that is in the public domain, and is intended to remain in the + # public domain. Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do + # not apply to the TZ Database or contributions that individuals make + # to it. Should any claims be made and substantiated against the TZ + # Database, the organization that is providing the IANA + # Considerations defined in this RFC, under the memorandum of + # understanding with the IETF, currently ICANN, may act in accordance + # with all competent court orders. No ownership claims will be made + # by ICANN or the IETF Trust on the database or the code. Any person + # making a contribution to the database or code waives all rights to + # future claims in that contribution or in the TZ Database. + +---------------------------------------------------------------------- + +Google double-conversion + +Copyright 2006-2011, the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +---------------------------------------------------------------------- + +File: install-sh (only for ICU4C) + + +Copyright 1991 by the Massachusetts Institute of Technology + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name of M.I.T. not be used in advertising or +publicity pertaining to distribution of the software without specific, +written prior permission. M.I.T. makes no representations about the +suitability of this software for any purpose. It is provided "as is" +without express or implied warranty. diff --git a/Vendor/SwiftWindowsRuntime/curl/COPYING b/Vendor/SwiftWindowsRuntime/curl/COPYING new file mode 100644 index 0000000..d9e7e0b --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/curl/COPYING @@ -0,0 +1,22 @@ +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1996 - 2024, Daniel Stenberg, , and many +contributors, see the THANKS file. + +All rights reserved. + +Permission to use, copy, modify, and distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright +notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall not +be used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization of the copyright holder. diff --git a/Vendor/SwiftWindowsRuntime/icu/LICENSE b/Vendor/SwiftWindowsRuntime/icu/LICENSE new file mode 100644 index 0000000..9f54372 --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/icu/LICENSE @@ -0,0 +1,512 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2016-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +---------------------------------------------------------------------- + +Third-Party Software Licenses + +This section contains third-party software notices and/or additional +terms for licensed third-party software components included within ICU +libraries. + +---------------------------------------------------------------------- + +ICU License - ICU 1.8.1 to ICU 57.1 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1995-2016 International Business Machines Corporation and others +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, and/or sell copies of the Software, and to permit persons +to whom the Software is furnished to do so, provided that the above +copyright notice(s) and this permission notice appear in all copies of +the Software and that both the above copyright notice(s) and this +permission notice appear in supporting documentation. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY +SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, use +or other dealings in this Software without prior written authorization +of the copyright holder. + +All trademarks and registered trademarks mentioned herein are the +property of their respective owners. + +---------------------------------------------------------------------- + +Chinese/Japanese Word Break Dictionary Data (cjdict.txt) + + # The Google Chrome software developed by Google is licensed under + # the BSD license. Other software included in this distribution is + # provided under other licenses, as set forth below. + # + # The BSD License + # http://opensource.org/licenses/bsd-license.php + # Copyright (C) 2006-2008, Google Inc. + # + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, + # this list of conditions and the following disclaimer. + # Redistributions in binary form must reproduce the above + # copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided with + # the distribution. + # Neither the name of Google Inc. nor the names of its + # contributors may be used to endorse or promote products derived from + # this software without specific prior written permission. + # + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + # + # + # The word list in cjdict.txt are generated by combining three word lists + # listed below with further processing for compound word breaking. The + # frequency is generated with an iterative training against Google web + # corpora. + # + # * Libtabe (Chinese) + # - https://sourceforge.net/project/?group_id=1519 + # - Its license terms and conditions are shown below. + # + # * IPADIC (Japanese) + # - http://chasen.aist-nara.ac.jp/chasen/distribution.html + # - Its license terms and conditions are shown below. + # + # ---------COPYING.libtabe ---- BEGIN-------------------- + # + # /* + # * Copyright (c) 1999 TaBE Project. + # * Copyright (c) 1999 Pai-Hsiang Hsiao. + # * All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the TaBE Project nor the names of its + # * contributors may be used to endorse or promote products derived + # * from this software without specific prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # /* + # * Copyright (c) 1999 Computer Systems and Communication Lab, + # * Institute of Information Science, Academia + # * Sinica. All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the Computer Systems and Communication Lab + # * nor the names of its contributors may be used to endorse or + # * promote products derived from this software without specific + # * prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # Copyright 1996 Chih-Hao Tsai @ Beckman Institute, + # University of Illinois + # c-tsai4@uiuc.edu http://casper.beckman.uiuc.edu/~c-tsai4 + # + # ---------------COPYING.libtabe-----END-------------------------------- + # + # + # ---------------COPYING.ipadic-----BEGIN------------------------------- + # + # Copyright 2000, 2001, 2002, 2003 Nara Institute of Science + # and Technology. All Rights Reserved. + # + # Use, reproduction, and distribution of this software is permitted. + # Any copy of this software, whether in its original form or modified, + # must include both the above copyright notice and the following + # paragraphs. + # + # Nara Institute of Science and Technology (NAIST), + # the copyright holders, disclaims all warranties with regard to this + # software, including all implied warranties of merchantability and + # fitness, in no event shall NAIST be liable for + # any special, indirect or consequential damages or any damages + # whatsoever resulting from loss of use, data or profits, whether in an + # action of contract, negligence or other tortuous action, arising out + # of or in connection with the use or performance of this software. + # + # A large portion of the dictionary entries + # originate from ICOT Free Software. The following conditions for ICOT + # Free Software applies to the current dictionary as well. + # + # Each User may also freely distribute the Program, whether in its + # original form or modified, to any third party or parties, PROVIDED + # that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear + # on, or be attached to, the Program, which is distributed substantially + # in the same form as set out herein and that such intended + # distribution, if actually made, will neither violate or otherwise + # contravene any of the laws and regulations of the countries having + # jurisdiction over the User or the intended distribution itself. + # + # NO WARRANTY + # + # The program was produced on an experimental basis in the course of the + # research and development conducted during the project and is provided + # to users as so produced on an experimental basis. Accordingly, the + # program is provided without any warranty whatsoever, whether express, + # implied, statutory or otherwise. The term "warranty" used herein + # includes, but is not limited to, any warranty of the quality, + # performance, merchantability and fitness for a particular purpose of + # the program and the nonexistence of any infringement or violation of + # any right of any third party. + # + # Each user of the program will agree and understand, and be deemed to + # have agreed and understood, that there is no warranty whatsoever for + # the program and, accordingly, the entire risk arising from or + # otherwise connected with the program is assumed by the user. + # + # Therefore, neither ICOT, the copyright holder, or any other + # organization that participated in or was otherwise related to the + # development of the program and their respective officials, directors, + # officers and other employees shall be held liable for any and all + # damages, including, without limitation, general, special, incidental + # and consequential damages, arising out of or otherwise in connection + # with the use or inability to use the program or any product, material + # or result produced or otherwise obtained by using the program, + # regardless of whether they have been advised of, or otherwise had + # knowledge of, the possibility of such damages at any time during the + # project or thereafter. Each user will be deemed to have agreed to the + # foregoing by his or her commencement of use of the program. The term + # "use" as used herein includes, but is not limited to, the use, + # modification, copying and distribution of the program and the + # production of secondary products from the program. + # + # In the case where the program, whether in its original form or + # modified, was distributed or delivered to or received by a user from + # any person, organization or entity other than ICOT, unless it makes or + # grants independently of ICOT any specific warranty to the user in + # writing, such person, organization or entity, will also be exempted + # from and not be held liable to the user for any such damages as noted + # above as far as the program is concerned. + # + # ---------------COPYING.ipadic-----END---------------------------------- + +---------------------------------------------------------------------- + +Lao Word Break Dictionary Data (laodict.txt) + + # Copyright (C) 2016 and later: Unicode, Inc. and others. + # License & terms of use: http://www.unicode.org/copyright.html + # Copyright (c) 2015 International Business Machines Corporation + # and others. All Rights Reserved. + # + # Project: https://github.com/rober42539/lao-dictionary + # Dictionary: https://github.com/rober42539/lao-dictionary/laodict.txt + # License: https://github.com/rober42539/lao-dictionary/LICENSE.txt + # (copied below) + # + # This file is derived from the above dictionary version of Nov 22, 2020 + # ---------------------------------------------------------------------- + # Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell. + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, this + # list of conditions and the following disclaimer. Redistributions in binary + # form must reproduce the above copyright notice, this list of conditions and + # the following disclaimer in the documentation and/or other materials + # provided with the distribution. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # OF THE POSSIBILITY OF SUCH DAMAGE. + # -------------------------------------------------------------------------- + +---------------------------------------------------------------------- + +Burmese Word Break Dictionary Data (burmesedict.txt) + + # Copyright (c) 2014 International Business Machines Corporation + # and others. All Rights Reserved. + # + # This list is part of a project hosted at: + # github.com/kanyawtech/myanmar-karen-word-lists + # + # -------------------------------------------------------------------------- + # Copyright (c) 2013, LeRoy Benjamin Sharon + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions + # are met: Redistributions of source code must retain the above + # copyright notice, this list of conditions and the following + # disclaimer. Redistributions in binary form must reproduce the + # above copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided + # with the distribution. + # + # Neither the name Myanmar Karen Word Lists, nor the names of its + # contributors may be used to endorse or promote products derived + # from this software without specific prior written permission. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS + # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF + # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + # SUCH DAMAGE. + # -------------------------------------------------------------------------- + +---------------------------------------------------------------------- + +Time Zone Database + + ICU uses the public domain data and code derived from Time Zone +Database for its time zone support. The ownership of the TZ database +is explained in BCP 175: Procedure for Maintaining the Time Zone +Database section 7. + + # 7. Database Ownership + # + # The TZ database itself is not an IETF Contribution or an IETF + # document. Rather it is a pre-existing and regularly updated work + # that is in the public domain, and is intended to remain in the + # public domain. Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do + # not apply to the TZ Database or contributions that individuals make + # to it. Should any claims be made and substantiated against the TZ + # Database, the organization that is providing the IANA + # Considerations defined in this RFC, under the memorandum of + # understanding with the IETF, currently ICANN, may act in accordance + # with all competent court orders. No ownership claims will be made + # by ICANN or the IETF Trust on the database or the code. Any person + # making a contribution to the database or code waives all rights to + # future claims in that contribution or in the TZ Database. + +---------------------------------------------------------------------- + +Google double-conversion + +Copyright 2006-2011, the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +---------------------------------------------------------------------- + +File: aclocal.m4 (only for ICU4C) +Section: pkg.m4 - Macros to locate and utilise pkg-config. + + +Copyright © 2004 Scott James Remnant . +Copyright © 2012-2015 Dan Nicholson + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA +02111-1307, USA. + +As a special exception to the GNU General Public License, if you +distribute this file as part of a program that contains a +configuration script generated by Autoconf, you may include it under +the same distribution terms that you use for the rest of that +program. + + +(The condition for the exception is fulfilled because +ICU4C includes a configuration script generated by Autoconf, +namely the `configure` script.) + +---------------------------------------------------------------------- + +File: config.guess (only for ICU4C) + + +This file is free software; you can redistribute it and/or modify it +under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, see . + +As a special exception to the GNU General Public License, if you +distribute this file as part of a program that contains a +configuration script generated by Autoconf, you may include it under +the same distribution terms that you use for the rest of that +program. This Exception is an additional permission under section 7 +of the GNU General Public License, version 3 ("GPLv3"). + + +(The condition for the exception is fulfilled because +ICU4C includes a configuration script generated by Autoconf, +namely the `configure` script.) + +---------------------------------------------------------------------- + +File: install-sh (only for ICU4C) + + +Copyright 1991 by the Massachusetts Institute of Technology + +Permission to use, copy, modify, distribute, and sell this software and its +documentation for any purpose is hereby granted without fee, provided that +the above copyright notice appear in all copies and that both that +copyright notice and this permission notice appear in supporting +documentation, and that the name of M.I.T. not be used in advertising or +publicity pertaining to distribution of the software without specific, +written prior permission. M.I.T. makes no representations about the +suitability of this software for any purpose. It is provided "as is" +without express or implied warranty. diff --git a/Vendor/SwiftWindowsRuntime/sources.json b/Vendor/SwiftWindowsRuntime/sources.json new file mode 100644 index 0000000..5930af2 --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/sources.json @@ -0,0 +1,239 @@ +{ + "schema_version": 1, + "swift_release": "6.2.3", + "swift_source_tag": "swift-6.2.3-RELEASE", + "sources": [ + { + "repository": "swiftlang/swift", + "tag": "swift-6.2.3-RELEASE", + "revision": "484e622d1c0afcae5b12a31c090a74ad0901e44f", + "source_path": "LICENSE.txt", + "url": "https://raw.githubusercontent.com/swiftlang/swift/484e622d1c0afcae5b12a31c090a74ad0901e44f/LICENSE.txt", + "sha256": "770af8291f708538d8ff885a0bbc4e045cd700531741c4f99528d435c14d7f55", + "bytes": 11751, + "file": "swift/LICENSE.txt" + }, + { + "repository": "swiftlang/swift-corelibs-foundation", + "tag": "swift-6.2.3-RELEASE", + "revision": "edfa17dd3623877a38967ae3edd356942fe4f655", + "source_path": "LICENSE", + "url": "https://raw.githubusercontent.com/swiftlang/swift-corelibs-foundation/edfa17dd3623877a38967ae3edd356942fe4f655/LICENSE", + "sha256": "0ca84095d1de77cad1a866ebbad28f8db98a0826c6055d92b0608c5baa1643e2", + "bytes": 11759, + "file": "swift-corelibs-foundation/LICENSE" + }, + { + "repository": "swiftlang/swift-foundation", + "tag": "swift-6.2.3-RELEASE", + "revision": "7242fa9cb8993aaf9a0a72522206b96a45da9c28", + "source_path": "LICENSE.md", + "url": "https://raw.githubusercontent.com/swiftlang/swift-foundation/7242fa9cb8993aaf9a0a72522206b96a45da9c28/LICENSE.md", + "sha256": "2245a990b635558be210fb3eb4f8a6f7a49aebc0fefbf5859146a65ddc7ddcf3", + "bytes": 11750, + "file": "swift-foundation/LICENSE.md" + }, + { + "repository": "swiftlang/swift-foundation", + "tag": "swift-6.2.3-RELEASE", + "revision": "7242fa9cb8993aaf9a0a72522206b96a45da9c28", + "source_path": "NOTICE.txt", + "url": "https://raw.githubusercontent.com/swiftlang/swift-foundation/7242fa9cb8993aaf9a0a72522206b96a45da9c28/NOTICE.txt", + "sha256": "c2e757911b86a76230099804831d8d847a865e88b5912171627492a7a49f0456", + "bytes": 15315, + "file": "swift-foundation/NOTICE.txt" + }, + { + "repository": "swiftlang/swift-foundation-icu", + "tag": "swift-6.2.3-RELEASE", + "revision": "ddd11fa3f970bd34924c418835986167b8e6efeb", + "source_path": "LICENSE.md", + "url": "https://raw.githubusercontent.com/swiftlang/swift-foundation-icu/ddd11fa3f970bd34924c418835986167b8e6efeb/LICENSE.md", + "sha256": "2245a990b635558be210fb3eb4f8a6f7a49aebc0fefbf5859146a65ddc7ddcf3", + "bytes": 11750, + "file": "swift-foundation-icu/LICENSE.md" + }, + { + "repository": "swiftlang/swift-corelibs-libdispatch", + "tag": "swift-6.2.3-RELEASE", + "revision": "2df91f94651f2d924d7506c9d14685929386d779", + "source_path": "LICENSE", + "url": "https://raw.githubusercontent.com/swiftlang/swift-corelibs-libdispatch/2df91f94651f2d924d7506c9d14685929386d779/LICENSE", + "sha256": "0ca84095d1de77cad1a866ebbad28f8db98a0826c6055d92b0608c5baa1643e2", + "bytes": 11759, + "file": "swift-corelibs-libdispatch/LICENSE" + }, + { + "repository": "swiftlang/swift-experimental-string-processing", + "tag": "swift-6.2.3-RELEASE", + "revision": "91177e6225c63e885872b83d48e254b1270fa15a", + "source_path": "LICENSE.txt", + "url": "https://raw.githubusercontent.com/swiftlang/swift-experimental-string-processing/91177e6225c63e885872b83d48e254b1270fa15a/LICENSE.txt", + "sha256": "770af8291f708538d8ff885a0bbc4e045cd700531741c4f99528d435c14d7f55", + "bytes": 11751, + "file": "swift-experimental-string-processing/LICENSE.txt" + }, + { + "repository": "curl/curl", + "tag": "curl-8_9_1", + "revision": "83bedbd730d62b83744cc26fa0433d3f6e2e4cd6", + "source_path": "COPYING", + "url": "https://raw.githubusercontent.com/curl/curl/83bedbd730d62b83744cc26fa0433d3f6e2e4cd6/COPYING", + "sha256": "adb1fc06547fd136244179809f7b7c2d2ae6c4534f160aa513af9b6a12866a32", + "bytes": 1088, + "file": "curl/COPYING" + }, + { + "repository": "madler/zlib", + "tag": "v1.3.1", + "revision": "51b7f2abdade71cd9bb0e7a373ef2610ec6f9daf", + "source_path": "LICENSE", + "url": "https://raw.githubusercontent.com/madler/zlib/51b7f2abdade71cd9bb0e7a373ef2610ec6f9daf/LICENSE", + "sha256": "845efc77857d485d91fb3e0b884aaa929368c717ae8186b66fe1ed2495753243", + "bytes": 1002, + "file": "zlib/LICENSE" + }, + { + "repository": "unicode-org/icu", + "tag": "release-74-1", + "revision": "9edac7b78327a1cb58db29e2714b15f9fa14e4d7", + "source_path": "LICENSE", + "url": "https://raw.githubusercontent.com/unicode-org/icu/9edac7b78327a1cb58db29e2714b15f9fa14e4d7/LICENSE", + "sha256": "17510cf7a58b4879b887ec05a45d72cf1b73544dd9ec7e72f20110ed104229ee", + "bytes": 25185, + "file": "icu/LICENSE", + "relationship": "FoundationICU vendored uvernum.h identifies ICU74.1.0; includes original Unicode/ICU and third-party notices." + }, + { + "repository": "apple-oss-distributions/ICU", + "tag": "ICU-74000.403", + "revision": "c9f69cae28475b0a32288a48fe223d07b1be4fd3", + "source_path": "LICENSE", + "url": "https://raw.githubusercontent.com/apple-oss-distributions/ICU/c9f69cae28475b0a32288a48fe223d07b1be4fd3/LICENSE", + "sha256": "92841ff27a1b2d1030b00e0958af4d1f70353ff42c5dad7041f1950e909ccc5e", + "bytes": 22559, + "file": "apple-icu/LICENSE", + "relationship": "Supplemental Apple ICU74 distribution notices. FoundationICU README identifies Apple OSS ICU extraction, but its exact Apple extraction tag is not established by this notice record." + } + ], + "runtime_libraries": { + "swiftcore.dll": { + "sources": [ + "swift/LICENSE.txt" + ], + "license_scope": "open-source" + }, + "swift_concurrency.dll": { + "sources": [ + "swift/LICENSE.txt" + ], + "license_scope": "open-source" + }, + "swiftsynchronization.dll": { + "sources": [ + "swift/LICENSE.txt" + ], + "license_scope": "open-source" + }, + "swiftwinsdk.dll": { + "sources": [ + "swift/LICENSE.txt" + ], + "license_scope": "open-source" + }, + "swiftcrt.dll": { + "sources": [ + "swift/LICENSE.txt" + ], + "license_scope": "open-source" + }, + "foundation.dll": { + "sources": [ + "swift-corelibs-foundation/LICENSE" + ], + "license_scope": "open-source" + }, + "foundationessentials.dll": { + "sources": [ + "swift-foundation/LICENSE.md", + "swift-foundation/NOTICE.txt" + ], + "license_scope": "open-source" + }, + "foundationinternationalization.dll": { + "sources": [ + "swift-foundation/LICENSE.md", + "swift-foundation/NOTICE.txt" + ], + "license_scope": "open-source" + }, + "foundationnetworking.dll": { + "sources": [ + "swift-corelibs-foundation/LICENSE", + "curl/COPYING", + "zlib/LICENSE" + ], + "license_scope": "open-source" + }, + "dispatch.dll": { + "sources": [ + "swift-corelibs-libdispatch/LICENSE" + ], + "license_scope": "open-source" + }, + "blocksruntime.dll": { + "sources": [ + "swift-corelibs-libdispatch/LICENSE" + ], + "license_scope": "open-source" + }, + "swiftdispatch.dll": { + "sources": [ + "swift/LICENSE.txt", + "swift-corelibs-libdispatch/LICENSE" + ], + "license_scope": "open-source" + }, + "swift_regexparser.dll": { + "sources": [ + "swift-experimental-string-processing/LICENSE.txt" + ], + "license_scope": "open-source" + }, + "swift_stringprocessing.dll": { + "sources": [ + "swift-experimental-string-processing/LICENSE.txt" + ], + "license_scope": "open-source" + }, + "swiftregexbuilder.dll": { + "sources": [ + "swift-experimental-string-processing/LICENSE.txt" + ], + "license_scope": "open-source" + }, + "_foundationicu.dll": { + "sources": [ + "swift-foundation-icu/LICENSE.md", + "icu/LICENSE", + "apple-icu/LICENSE" + ], + "license_scope": "open-source" + }, + "msvcp140.dll": { + "sources": [], + "license_scope": "Microsoft Visual C++ Redistributable", + "redistribution_reference": "https://learn.microsoft.com/en-us/cpp/windows/redistributing-visual-cpp-files?view=msvc-170" + }, + "vcruntime140.dll": { + "sources": [], + "license_scope": "Microsoft Visual C++ Redistributable", + "redistribution_reference": "https://learn.microsoft.com/en-us/cpp/windows/redistributing-visual-cpp-files?view=msvc-170" + }, + "vcruntime140_1.dll": { + "sources": [], + "license_scope": "Microsoft Visual C++ Redistributable", + "redistribution_reference": "https://learn.microsoft.com/en-us/cpp/windows/redistributing-visual-cpp-files?view=msvc-170" + } + } +} diff --git a/Vendor/SwiftWindowsRuntime/swift-corelibs-foundation/LICENSE b/Vendor/SwiftWindowsRuntime/swift-corelibs-foundation/LICENSE new file mode 100644 index 0000000..f92d51a --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/swift-corelibs-foundation/LICENSE @@ -0,0 +1,211 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +### Runtime Library Exception to the Apache 2.0 License: ### + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. diff --git a/Vendor/SwiftWindowsRuntime/swift-corelibs-libdispatch/LICENSE b/Vendor/SwiftWindowsRuntime/swift-corelibs-libdispatch/LICENSE new file mode 100644 index 0000000..f92d51a --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/swift-corelibs-libdispatch/LICENSE @@ -0,0 +1,211 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +### Runtime Library Exception to the Apache 2.0 License: ### + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. diff --git a/Vendor/SwiftWindowsRuntime/swift-experimental-string-processing/LICENSE.txt b/Vendor/SwiftWindowsRuntime/swift-experimental-string-processing/LICENSE.txt new file mode 100644 index 0000000..61b0c78 --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/swift-experimental-string-processing/LICENSE.txt @@ -0,0 +1,211 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +## Runtime Library Exception to the Apache 2.0 License: ## + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. diff --git a/Vendor/SwiftWindowsRuntime/swift-foundation-icu/LICENSE.md b/Vendor/SwiftWindowsRuntime/swift-foundation-icu/LICENSE.md new file mode 100644 index 0000000..004d67a --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/swift-foundation-icu/LICENSE.md @@ -0,0 +1,211 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +## Runtime Library Exception to the Apache 2.0 License: ## + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. \ No newline at end of file diff --git a/Vendor/SwiftWindowsRuntime/swift-foundation/LICENSE.md b/Vendor/SwiftWindowsRuntime/swift-foundation/LICENSE.md new file mode 100644 index 0000000..004d67a --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/swift-foundation/LICENSE.md @@ -0,0 +1,211 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +## Runtime Library Exception to the Apache 2.0 License: ## + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. \ No newline at end of file diff --git a/Vendor/SwiftWindowsRuntime/swift-foundation/NOTICE.txt b/Vendor/SwiftWindowsRuntime/swift-foundation/NOTICE.txt new file mode 100644 index 0000000..b8568b2 --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/swift-foundation/NOTICE.txt @@ -0,0 +1,279 @@ + The Foundation Project + ====================== + +This product contains a derivation of Fabian Fett's 'Base64.swift'. + + * LICENSE (Apache License 2.0): + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + * HOMEPAGE: + * https://github.com/swift-extras/swift-extras-base64 + +--- + +This product contains a derivation of Daniel Lemire's 'chromiumbase64' +implementation in 'fastbase64'. + + * LICENSE: + + Copyright (c) 2015-2016, Wojciech Muła, Alfred Klomp, Daniel Lemire + (Unless otherwise stated in the source code) + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS + IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + * HOMEPAGE: + * https://github.com/lemire/fastbase64 + +--- + +This product contains a derivation of Nick Galbreath's 'base64' +implementation in 'stringencoders'. + + * LICENSE (The MIT License): + + The MIT License (MIT) + + Copyright (c) 2016 Nick Galbreath + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + * HOMEPAGE: + * https://github.com/client9/stringencoders diff --git a/Vendor/SwiftWindowsRuntime/swift/LICENSE.txt b/Vendor/SwiftWindowsRuntime/swift/LICENSE.txt new file mode 100644 index 0000000..61b0c78 --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/swift/LICENSE.txt @@ -0,0 +1,211 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +## Runtime Library Exception to the Apache 2.0 License: ## + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. diff --git a/Vendor/SwiftWindowsRuntime/zlib/LICENSE b/Vendor/SwiftWindowsRuntime/zlib/LICENSE new file mode 100644 index 0000000..ab8ee6f --- /dev/null +++ b/Vendor/SwiftWindowsRuntime/zlib/LICENSE @@ -0,0 +1,22 @@ +Copyright notice: + + (C) 1995-2022 Jean-loup Gailly and Mark Adler + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu diff --git a/computer-mcp-plugin.toml b/computer-mcp-plugin.toml index 8106006..9eae723 100644 --- a/computer-mcp-plugin.toml +++ b/computer-mcp-plugin.toml @@ -1,13 +1,25 @@ id = 'codex' name = 'Codex' -version = "0.2.1" +version = "0.3.0" description = 'Codex execution and protocol inspection through standard MCP.' [[mcp]] id = 'app-server' transport = 'stdio' -executable = { path = 'bin/codex-mcp-adapter' } +executable = { platform_paths = { macos = 'bin/codex-mcp-adapter', windows = 'bin/codex-mcp-adapter.exe' } } capabilities = ['tools'] [compatibility] +minimum_host = '1.3.0' +platforms = ['macos', 'windows'] +architectures = ['arm64', 'x86_64'] + +[[compatibility.artifacts]] +name = 'codex-plugin-macos-arm64.zip' +platforms = ['macos'] architectures = ['arm64'] + +[[compatibility.artifacts]] +name = 'codex-plugin-windows-x86_64.zip' +platforms = ['windows'] +architectures = ['x86_64']