diff --git a/.dockerignore b/.dockerignore index ee773610d57..7aaa533fe76 100644 --- a/.dockerignore +++ b/.dockerignore @@ -42,3 +42,5 @@ packages/paperclip-runner/scripts/*-smoke.mjs # they must survive the *.test.ts rule above. !packages/paperclip-runner/src/**/*.test.ts !packages/paperclip-runner/src/**/*.test.tsx + +packages/grok-acp/bin diff --git a/.env.runner-e2e.example b/.env.runner-e2e.example index 85c99a7836a..992d56f820a 100644 --- a/.env.runner-e2e.example +++ b/.env.runner-e2e.example @@ -2,6 +2,7 @@ # Never commit the local file or put these values in fixture source. OPENAI_API_KEY= ANTHROPIC_API_KEY= +XAI_API_KEY= OPENROUTER_API_KEY= DAYTONA_API_KEY= diff --git a/.github/workflows/docker-runner-check.yml b/.github/workflows/docker-runner-check.yml index 1f6002b4cd8..19e1c2d08b4 100644 --- a/.github/workflows/docker-runner-check.yml +++ b/.github/workflows/docker-runner-check.yml @@ -1,6 +1,16 @@ name: Docker Runner check on: + workflow_dispatch: + inputs: + publish_eval_image: + description: "Publish an immutable Daytona qualification image on the EC2 fleet (no provider credentials)" + type: boolean + default: false + verify_source: + description: "Run broad source checks on EC2" + type: boolean + default: false pull_request: paths: - .github/workflows/docker-runner-check.yml @@ -14,11 +24,12 @@ on: permissions: {} concurrency: - group: docker-runner-check-${{ github.event.pull_request.number }} + group: docker-runner-check-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: runner: + if: github.event_name == 'pull_request' name: Compile isolated native Runner runs-on: ubuntu-latest timeout-minutes: 20 @@ -39,3 +50,119 @@ jobs: # No registry credentials or image publication. - name: Verify native build and dependency cache reuse run: bash scripts/check-docker-runner-cache.sh + + + authorize_manual: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 5 + steps: + - name: Authorize an explicit maintainer image build + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + REPOSITORY_ID: ${{ github.repository_id }} + ACTOR_ID: ${{ github.actor_id }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + ALLOWED_IDS: ${{ vars.AWS_CI_TRUSTED_USER_IDS }} + run: | + set -euo pipefail + test "$REPOSITORY" = paperclipai/paperclip + test "$REPOSITORY_ID" = 1170821064 + jq -e 'type == "array" and length > 0 and all(.[]; type == "number")' <<< "$ALLOWED_IDS" >/dev/null + triggering_id="$(gh api "users/$TRIGGERING_ACTOR" --jq .id)" + for id in "$ACTOR_ID" "$triggering_id"; do + jq -e --argjson id "$id" 'index($id) != null' <<< "$ALLOWED_IDS" >/dev/null + done + + manual_image: + name: Build and verify on EC2 + needs: authorize_manual + runs-on: runs-on/fleet=paperclip-public-pr-x64/env=public-ci + timeout-minutes: 90 + permissions: + contents: read + packages: write + steps: + - name: Authorize an explicit maintainer image build + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + REPOSITORY_ID: ${{ github.repository_id }} + ACTOR_ID: ${{ github.actor_id }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + ALLOWED_IDS: ${{ vars.AWS_CI_TRUSTED_USER_IDS }} + run: | + set -euo pipefail + test "$REPOSITORY" = paperclipai/paperclip + test "$REPOSITORY_ID" = 1170821064 + jq -e 'type == "array" and length > 0 and all(.[]; type == "number")' <<< "$ALLOWED_IDS" >/dev/null + triggering_id="$(gh api "users/$TRIGGERING_ACTOR" --jq .id)" + for id in "$ACTOR_ID" "$triggering_id"; do + jq -e --argjson id "$id" 'index($id) != null' <<< "$ALLOWED_IDS" >/dev/null + done + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + package-manager-cache: false + - name: Resolve source dependencies without lifecycle scripts + run: | + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + mkdir -p remote-verification + sha256sum pnpm-lock.yaml > remote-verification/lock.sha256 + git rev-parse HEAD > remote-verification/source.txt + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + - if: inputs.publish_eval_image + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build checksum-verified Grok provider image + if: inputs.publish_eval_image + env: + SOURCE_SHA: ${{ github.sha }} + run: | + set -euo pipefail + image="ghcr.io/paperclipai/paperclip-daytona-runner:qualification-${SOURCE_SHA}-${GITHUB_RUN_ID}" + lock_sha="$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" + docker buildx build --platform linux/amd64 \ + --file docker/daytona-runner/Dockerfile \ + --build-arg "PAPERCLIP_RUNNER_SOURCE_REVISION=$SOURCE_SHA" \ + --build-arg "PAPERCLIP_RUNNER_CONTENT_ID=qualification-$SOURCE_SHA" \ + --build-arg "PAPERCLIP_RUNNER_LOCK_SHA256=$lock_sha" \ + --cache-from type=registry,ref=ghcr.io/paperclipai/paperclip-daytona-runner:e2e-buildcache-amd64 \ + --tag "$image" --metadata-file remote-verification/image.json --push . + digest="$(jq -r '."containerimage.digest"' remote-verification/image.json)" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + immutable="${image%:*}@$digest" + docker logout ghcr.io + docker buildx imagetools inspect "$immutable" >/dev/null + echo "$immutable" > remote-verification/image.txt + echo "Image: $immutable" >> "$GITHUB_STEP_SUMMARY" + - name: Verify repository on EC2 + if: inputs.verify_source + run: | + set -uo pipefail + pnpm install --frozen-lockfile --ignore-scripts + status=0 + for check in 'pnpm -r typecheck' 'pnpm test:run' 'pnpm check:token-gates' 'pnpm test:e2e:runner:typecheck' 'pnpm test:e2e:runner:unit' 'pnpm build'; do + label="$(echo "$check" | tr -cs 'a-zA-Z0-9' '-')" + bash -c "$check" > "remote-verification/$label.log" 2>&1 || status=1 + done + exit "$status" + - name: Retain source, image and verification evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: grok-remote-verification-${{ github.run_id }} + path: remote-verification/ + retention-days: 7 diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 9016efa30c2..6d7c4ae6255 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -1037,6 +1037,7 @@ jobs: OPENAI_API_KEY: ${{ matrix.credentialName == 'OPENAI_API_KEY' && secrets.OPENAI_API_KEY || '' }} ANTHROPIC_API_KEY: ${{ matrix.credentialName == 'ANTHROPIC_API_KEY' && secrets.ANTHROPIC_API_KEY || '' }} OPENROUTER_API_KEY: ${{ matrix.credentialName == 'OPENROUTER_API_KEY' && secrets.OPENROUTER_API_KEY || '' }} + XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }} DAYTONA_API_KEY: ${{ matrix.environmentId == 'daytona' && secrets.DAYTONA_API_KEY || '' }} PAPERCLIP_E2E_DAYTONA_IMAGE: ${{ needs.daytona_image.outputs.image }} PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH: ${{ github.workspace }}/packages/paperclip-runner/provider-pack diff --git a/doc/grok-native-runner.md b/doc/grok-native-runner.md new file mode 100644 index 00000000000..5cc10d8daee --- /dev/null +++ b/doc/grok-native-runner.md @@ -0,0 +1,70 @@ +# Grok Build native runner + +Select **Grok Build** in the native runner provider selector. The stored contract is +`adapterType: "paperclip_runner"` with `provider: "acpx"`, `acpxAgent: "grok"`, +and `model: "grok-4.7"`. Existing `grok_local` agents keep their legacy adapter. + +Grok Build speaks [ACP over stdio](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/15-agent-mode.md). +The runner owns `grok agent --no-leader stdio` through ACPX, including session +identity, cancellation, recovery and the authenticated Paperclip MCP bridge. +It does not add `--always-approve`. Restricted operations use the selected ACPX +permission policy and return the existing approval-required outcome. + +## Installation and identity + +Run `pnpm --filter @paperclipai/paperclip-runner install:grok` after installing +workspace dependencies. This explicitly downloads Grok Build 1.0.13, verifies +the native executable digest in `packages/grok-acp/platforms.json`, and installs +it privately. Only macOS arm64 and Linux x64 are admitted. Provider packs install +the same verified binary. No ambient `grok` from PATH is used by the native runner. +ACP must report the requested exact model; absent or mismatched identities fail. + +Instructions use Grok ACP session rules. Assigned skills live in the isolated +Grok home. Steering and goals are unsupported. Token and cost values remain +unknown when Grok does not report them; missing usage is not zero usage. + +## Authentication + +Use the existing Grok company connection/login flow for subscription execution. +An explicitly selected company-secret `XAI_API_KEY` selects paid API execution. +There is no automatic subscription-to-API fallback. Remote execution cannot +borrow the operator's home credentials. Local execution can use the operator's +existing Grok login when no company login has been selected. + +Only the selected credential is staged in the private runtime home. An ownership +lease fences concurrent processes. After the provider exits, refreshed credentials +are copied back through Grok's existing identity and refresh checks. Runtime +credentials and refresh handoffs are excluded from workspace backups and removed +on close. Host configuration, other provider credentials and unselected keys are +not forwarded to Grok. + +## Evaluation + +The private `paperclip-evals` repository maintains `live-acpx-grok-4.7.json` and +`rosters/live-acpx-grok.json`. The roster covers all 35 protocol cases. Its campaign +lane remains disabled pending complete live qualification. + +Product E2E exposes `runner-acpx-grok` in core local/Daytona compatibility and +local session integrity. The explicit `grok-qualification` suite covers replies, +planning approvals, structured questions and continuation after controller restart +in both environments. Run with `--suite grok-qualification`; it is excluded from +scheduled `--all`. Use the canonical Product E2E dashboard and Evalbook reports. + +`packages/paperclip-runner/scripts/grok-native-smoke.mjs` records explicit auth, +model/binary identity, MCP outcome, durable resume and restrictive permissions. +Pass `--auth subscription --auth-file /private/path/auth.json --output /private/report.json` +or `--auth api --output /private/report.json` with an explicitly supplied key. +Each attempt is retained; unknown usage and cost are null. + +Qualification requires the full protocol roster, selected product workflows, +subscription and API execution locally and on Daytona, and three successful +repetitions of core tool, approval and resume cases. Deterministic tests or a +single successful browser task do not establish that qualification. + +## Remote verification + +Do not run Docker on a developer laptop when using remote verification. The +`Docker Runner check` workflow offers maintainer-authorized manual EC2 image +builds and broad source checks without provider credentials. It records the +source revision, resolved lock digest and immutable image reference. Paid +Product E2E remains behind the protected default-branch workflow and environment. diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index cff873d3177..b1b6676f1ce 100644 --- a/docker/daytona-runner/Dockerfile +++ b/docker/daytona-runner/Dockerfile @@ -18,9 +18,9 @@ WORKDIR /workspace COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc tsconfig.base.json ./ COPY patches ./patches COPY scripts/link-plugin-dev-sdk.mjs ./scripts/link-plugin-dev-sdk.mjs -# CI owns pnpm-lock.yaml and regenerates it for manifest-only PRs. Include the -# complete workspace manifest graph so all source-owned patches apply, then -# resolve before the frozen install. This happens only during image creation. +# Include the complete workspace manifest graph so source-owned patches apply. +# Verify the reviewed lockfile as supplied; never resolve dependencies again +# against the registry during an immutable image build. COPY packages ./packages COPY server/package.json ./server/package.json COPY ui/package.json ./ui/package.json @@ -28,9 +28,8 @@ COPY cli/package.json ./cli/package.json # The complete resolved lock (including transitive integrity hashes) is reviewed. # Reject registry-time drift BEFORE installing packages or running lifecycle code. # Refresh this digest together with source/provider dependency changes. -ARG PAPERCLIP_RUNNER_LOCK_SHA256=4b796c312833ebf2be4c38228babc0292c76774fb40d43bd18b54bd6b205753d -RUN pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile \ - && printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \ +ARG PAPERCLIP_RUNNER_LOCK_SHA256=f05a4a9cad90c72fe5b0dc2530a8f265a2b71caec91761664d955a53321ddd2f +RUN printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \ && sha256sum -c /tmp/provider-lock.sha256 \ && pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...' ARG PAPERCLIP_RUNNER_SOURCE_REVISION @@ -54,7 +53,6 @@ ENV PATH=${PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT}/node_modules/.bin:/usr/local/sha # Never add a second CLI version to work around a runner compatibility pin. RUN npm uninstall -g @anthropic-ai/claude-code @openai/codex opencode-ai \ && npm install -g \ - @xai-official/grok@1.0.13 \ @google/gemini-cli@0.58.0 \ @moonshot-ai/kimi-code@0.41.0 \ && npm cache clean --force @@ -96,7 +94,7 @@ RUN set -eu; \ COPY --from=runnerd-build /workspace/packages/paperclip-runner/runner/target/release/paperclip-runnerd /usr/local/bin/paperclip-runnerd COPY --from=provider-pack-build /provider-pack /opt/paperclip-runner/provider-pack -RUN set -eu; for cli in codex claude opencode; do \ +RUN set -eu; for cli in codex claude opencode grok; do \ printf '#!/bin/sh\nexec /opt/paperclip-runner/provider-pack/node_modules/.bin/%s "$@"\n' "$cli" > "/usr/local/bin/$cli"; \ chmod 755 "/usr/local/bin/$cli"; \ done diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index b2835539206..25f2010392c 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -233,8 +233,8 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record createHash("sha256").update(bytes).digest("hex"); +const installed = await readFile(destination).catch(() => null); +if (!installed || digest(installed) !== platform.sha256) { + const url = `https://x.ai/cli/grok-${manifest.version}-${platform.artifact}.gz`; + const response = await fetch(url, { signal: AbortSignal.timeout(120_000) }); + if (!response.ok) throw new Error(`Grok download failed: HTTP ${response.status}`); + const bytes = gunzipSync(Buffer.from(await response.arrayBuffer()), { maxOutputLength: 384 * 1024 * 1024 }); + if (digest(bytes) !== platform.sha256) throw new Error("Grok binary digest mismatch"); + await mkdir(new URL("./bin/", import.meta.url), { recursive: true }); + const temporary = `${destination}.${process.pid}.tmp`; + try { + await writeFile(temporary, bytes, { flag: "wx", mode: 0o700 }); + await rename(temporary, destination); + } finally { await rm(temporary, { force: true }); } +} +await chmod(destination, 0o700); +console.log(`Verified Grok ${manifest.version} (${process.platform}-${process.arch})`); diff --git a/packages/grok-acp/launcher.cjs b/packages/grok-acp/launcher.cjs new file mode 100755 index 00000000000..8c3267099b0 --- /dev/null +++ b/packages/grok-acp/launcher.cjs @@ -0,0 +1,9 @@ +// This launcher is admitted by digest and receives a verified native executable. +// Never resolve an executable through PATH or accept command-line overrides. +const executable = process.env.PAPERCLIP_GROK_VERIFIED_EXECUTABLE; +if (!executable || !require("node:path").isAbsolute(executable)) { + throw new Error("Verified Grok executable is unavailable"); +} +const environment = { ...process.env }; +delete environment.PAPERCLIP_GROK_VERIFIED_EXECUTABLE; +process.execve(executable, [executable, "agent", "--no-leader", "stdio"], environment); diff --git a/packages/grok-acp/package.json b/packages/grok-acp/package.json new file mode 100644 index 00000000000..87de39f380d --- /dev/null +++ b/packages/grok-acp/package.json @@ -0,0 +1,21 @@ +{ + "name": "@paperclipai/grok-acp", + "version": "1.0.13", + "private": true, + "license": "MIT", + "bin": { + "paperclip-grok-acp": "launcher.cjs" + }, + "files": [ + "launcher.cjs", + "bin", + "install.mjs", + "platforms.json" + ], + "scripts": { + "install:binary": "node install.mjs" + }, + "exports": { + "./package.json": "./package.json" + } +} diff --git a/packages/grok-acp/platforms.json b/packages/grok-acp/platforms.json new file mode 100644 index 00000000000..7731434c275 --- /dev/null +++ b/packages/grok-acp/platforms.json @@ -0,0 +1,13 @@ +{ + "version": "1.0.13", + "platforms": { + "darwin-arm64": { + "artifact": "macos-aarch64", + "sha256": "8669e0fdadceec25b8c159c355f427ffbd82583525d774b6ab1522197ea83b80" + }, + "linux-x64": { + "artifact": "linux-x86_64", + "sha256": "edf79521581bb5e6b95abef848491a6a742e860da3e237ebe86a280d30dce4c1" + } + } +} diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index fbf51aa98b0..eed93fe9184 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -159,7 +159,8 @@ "browser:dev": "pnpm run build:typescript && pnpm run build:runner-binaries && vite --config vite.config.ts", "browser:preview": "vite preview --config vite.config.ts", "verify": "pnpm run build && pnpm run typecheck && pnpm run check:replay-goldens && pnpm run test && pnpm run test:sdk && pnpm run test:scenarios && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:sdk && pnpm run test:browser:scenarios && pnpm run check:forbidden-imports && pnpm run check:tracked-imports && pnpm run check:numbered-milestones && pnpm run check:package-boundaries && pnpm run check:clean-consumers && pnpm run docs:validate && pnpm run check:conformance-parity && pnpm run check:replay-parity && pnpm run trace:conformance && pnpm run replay:fixture && pnpm run trace:local-runner -- --quiet", - "verify:rootless": "bash scripts/verify-rootless-linux.sh" + "verify:rootless": "bash scripts/verify-rootless-linux.sh", + "install:grok": "pnpm --filter @paperclipai/grok-acp install:binary" }, "dependencies": { "@agentclientprotocol/claude-agent-acp": "0.73.0", @@ -171,7 +172,8 @@ "opencode-ai": "1.18.29", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", - "smol-toml": "^1.4.2" + "smol-toml": "^1.4.2", + "@paperclipai/grok-acp": "workspace:*" }, "peerDependencies": { "react": ">=18", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index e4cf725f1fe..67bef6bb5a9 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -162,6 +162,14 @@ impl AcpxProviderDescriptor { Some("0.153.4"), "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", ), + "grok" => ( + "grok-4.7", + "@paperclipai/grok-acp", + "1.0.13", + Some("@paperclipai/grok-acp"), + Some("1.0.13"), + "sha256:42fe296ec6fc0715c3509cec9671451bcd5bfdc7f185041101c8aac1e9ac8718", + ), "pi" => return Err(DurableRunnerError::invalid( "ACPX agent pi is not executable through the verified runnerd provider boundary", )), @@ -176,7 +184,7 @@ impl AcpxProviderDescriptor { || self.driver != "acpx_runtime" || self.provider_version != "0.13.1" || self.acpx_version != "0.13.1" - || (self.agent != "claude" && self.model != expected.0) + || (self.agent != "claude" && self.agent != "grok" && self.model != expected.0) || self.model.trim().is_empty() || self.model.len() > 1024 || self.agent_server_package != expected.1 diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs index 09038b02424..c64d02bc5b7 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_session.rs @@ -74,14 +74,15 @@ impl AcpxProviderSessionConfig { self.transport.validate()?; let qualified_model = match self.agent.as_str() { "claude" => "claude-sonnet-5", + "grok" => "grok-4.7", "codex" => "gpt-5.6-sol", _ => { return Err(LocalRunnerError::invalid( - "ACPX agent must be claude or codex", + "ACPX agent must be claude, codex, or grok", )) } }; - if self.agent != "claude" && self.model != qualified_model { + if self.agent != "claude" && self.agent != "grok" && self.model != qualified_model { return Err(LocalRunnerError::invalid(format!( "ACPX {} profile requires exact model {qualified_model}", self.agent diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 86602e4713d..409b87cf35f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -98,9 +98,10 @@ impl AcpxSidecarTransport { let credential_keys: &[&str] = match agent { "claude" => &["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], "codex" => &["OPENAI_API_KEY", "CODEX_API_KEY"], + "grok" => &["XAI_API_KEY", "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET"], _ => { return Err(LocalRunnerError::invalid( - "ACPX sidecar credentials require a qualified claude or codex agent", + "ACPX sidecar credentials require a qualified claude, codex, or grok agent", )) } }; diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs index b91d49ba21b..7223ae7c5f6 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs @@ -131,7 +131,7 @@ fn bootstraps_a_codex_session_and_confirms_run_identity() { fn validates_qualified_policy_and_tool_catalog_before_spawning() { let mut invalid_agent = config("bootstrap"); invalid_agent.agent = "pi".to_owned(); - assert!(start_error(&invalid_agent).contains("claude or codex")); + assert!(start_error(&invalid_agent).contains("claude, codex, or grok")); let mut unpinned = config("bootstrap"); unpinned.permission_mode_pinned = false; @@ -155,6 +155,8 @@ fn admits_custom_claude_models_and_legacy_codex_profile() { ("claude", "claude-sonnet-5"), ("claude", "claude-opus-5"), ("claude", "custom-provider-model"), + ("grok", "grok-4.7"), + ("grok", "future-exact-model"), ] { let mut qualified = config("bootstrap"); qualified.agent = agent.to_owned(); diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index 3b5e3fb18ac..1ef7428529d 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -188,6 +188,11 @@ try { ); writePortableExecutableShim("node", "node/bin/node"); writePortableExecutableShim("opencode", "opencode-ai/bin/opencode.exe"); + const grokPackage = dirname(packRequire.resolve("@paperclipai/grok-acp/package.json")); + const grokInstall = spawnSync(process.execPath, [join(grokPackage, "install.mjs")], { stdio: "inherit" }); + if (grokInstall.status !== 0) throw new Error("Pinned Grok installation failed"); + writePortableExecutableShim("grok", relative(realpathSync(join(temporaryRoot, "node_modules")), join(grokPackage, "bin/grok"))); + writePortableNodeShim("paperclip-grok-acp", "@paperclipai/grok-acp/launcher.cjs"); writePortableNodeShim("acpx", "acpx/dist/cli.js"); writePortableNodeShim( "claude-agent-acp", @@ -293,6 +298,7 @@ try { codex: "0.153.4", opencode: "1.18.29", acpx: "0.13.1", + grok: "1.0.13", claudeAcp: "0.73.0", codexAcp: "1.6.2", }, @@ -307,12 +313,17 @@ try { .update(distDigest) .digest("hex")}`, acpxProfileDigests: { + grok: "sha256:42fe296ec6fc0715c3509cec9671451bcd5bfdc7f185041101c8aac1e9ac8718", claude: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", codex: "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", }, artifacts: { + grokExecutable: { + path: "node_modules/@paperclipai/grok-acp/bin/grok", + sha256: sha256File(join(grokPackage, "bin/grok")), + }, nodeCommand: { path: nodeCommand, sha256: sha256File(join(temporaryRoot, nodeCommand)), diff --git a/packages/paperclip-runner/scripts/grok-native-smoke.mjs b/packages/paperclip-runner/scripts/grok-native-smoke.mjs new file mode 100644 index 00000000000..9b442f5959f --- /dev/null +++ b/packages/paperclip-runner/scripts/grok-native-smoke.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { randomUUID, createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFile, writeFile, mkdir, mkdtemp, rm, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { AcpxRuntimeHost } from '../dist/drivers/acpx/runtime-host.js'; +import { openQualifiedAcpxRuntime } from '../dist/drivers/acpx/codex-runtime-adapter.js'; +import { QUALIFIED_ACPX_PROFILES } from '../dist/drivers/acpx/qualified-profiles.js'; + +const { values } = parseArgs({ options: { auth: { type: 'string', default: 'subscription' }, 'auth-file': { type: 'string' }, output: { type: 'string' }, repetitions: { type: 'string', default: '3' } } }); +assert(values.output, 'An output path is required to retain every attempt'); +assert(['subscription', 'api'].includes(values.auth)); +const repetitions = Number(values.repetitions); +assert(Number.isSafeInteger(repetitions) && repetitions > 0 && repetitions <= 3); +const environment = { PATH: process.env.PATH }; +if (values.auth === 'api') { + assert(process.env.XAI_API_KEY, 'Explicit XAI_API_KEY is required'); + environment.XAI_API_KEY = process.env.XAI_API_KEY; +} else { + assert(values['auth-file'], 'An explicit subscription auth-file is required'); + environment.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET = await readFile(values['auth-file'], 'utf8'); +} +const report = { schema: 'paperclip.grok-native-smoke.v1', auth: values.auth, sourceRevision: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), sourceDirty: execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8' }).length > 0, profile: QUALIFIED_ACPX_PROFILES.grok, platform: `${process.platform}-${process.arch}`, startedAt: new Date().toISOString(), attempts: [] }; +const binary = await readFile(new URL('../../grok-acp/bin/grok', import.meta.url)); +report.binaryDigest = `sha256:${createHash('sha256').update(binary).digest('hex')}`; +async function persist() { await writeFile(resolve(values.output), JSON.stringify(report, null, 2)+'\n', { mode: 0o600 }); } +await persist(); +for (let repetition=1; repetition<=repetitions; repetition++) { + const root = await realpath(await mkdtemp(join(tmpdir(), 'paperclip-grok-smoke-'))); + await mkdir(join(root,'runtime'), { mode:0o700 }); await mkdir(join(root,'workspace'), { mode:0o700 }); + const nonce=randomUUID(); let calls=0; let host; + const attempt = { repetition, startedAt: new Date().toISOString(), status: 'running', checks: {}, usage: null, cost: null }; + report.attempts.push(attempt); await persist(); + const start = Date.now(); + const options = { runtimeDirectory:join(root,'runtime'), normalizedSessionId:'grok-smoke', workingDirectory:join(root,'workspace'), agent:'grok', model:'grok-4.7', permissionMode:'approve-all', environment, systemInstructions:'Use the requested MCP tool and retain its nonce in this session. Do not use other tools. Answer briefly.', semanticTools: { tools:[{ name:'check_context', description:'Read this task’s qualification nonce.', inputSchema:{type:'object', properties:{}, additionalProperties:false} }], handler:async call=>{assert.equal(call.tool,'check_context'); calls++; return {nonce};} } }; + const dependencies={openRuntime:openQualifiedAcpxRuntime, reportRetainedCleanupFailure:()=>{ attempt.checks.cleanup=false; }}; + async function turn(text,id) { + const handle=host.startTurn({text, requestId:id, signal:AbortSignal.timeout(120000)}); let output=''; const counts={}; + const drain=(async()=>{for await(const event of handle.events){counts[event.type]=(counts[event.type]??0)+1;if(event.type==='text_delta' && event.stream!=='thought')output+=event.text;}})(); + const result=await handle.result;await drain;attempt.lastTurnStatus=result.status;attempt.stopReason=result.stopReason;attempt.failureCode=result.error?.code??null;attempt.failureCategory=result.error?.category??null; attempt.lastEventCounts=counts;attempt.outputLength=output.length;if(result.status!=='completed') throw Object.assign(new Error('Provider turn did not complete'),{code:result.error?.code ?? result.status});return {output,counts}; + } + try { + host=await AcpxRuntimeHost.open({...options,signal:AbortSignal.timeout(45000)},dependencies); + const identity=host.identity(); assert.equal(identity.effectiveModel,'grok-4.7');attempt.checks.model=true; + const first=await turn('Call check_context exactly once, then reply with only the returned nonce.',`tool-${repetition}`); + attempt.toolCalls=calls;attempt.nonceObserved=first.output.includes(nonce);assert.equal(calls,1);assert(first.output.includes(nonce));attempt.checks.authenticatedTool=true;attempt.events=first.counts; + await host.close({reason:'verify persisted resume'});host=null; + host=await AcpxRuntimeHost.open({...options,expectedIdentity:identity,signal:AbortSignal.timeout(45000)},dependencies); + assert.equal(host.identity().agentSessionId,identity.agentSessionId); + const second=await turn('Without using tools, reply with only the nonce returned in the previous turn.',`resume-${repetition}`); + assert(second.output.includes(nonce));assert.equal(calls,1);attempt.checks.resumeIdentity=true; + await host.close({reason:'verify restrictive permissions'});host=null; + const restricted={...options,normalizedSessionId:'grok-restricted',permissionMode:'approve-reads'}; + host=await AcpxRuntimeHost.open({...restricted,signal:AbortSignal.timeout(45000)},dependencies); + let restrictedResult; + try { await turn('Use the shell to create a file named restricted-proof.txt in the working directory, then stop.',`permission-${repetition}`); } + catch(error) { restrictedResult=error.code ?? error.name; } + // Restricted Grok writes must route through the existing approval-required outcome. + assert.equal(restrictedResult,'approval_required'); + await assert.rejects(readFile(join(root,'workspace','restricted-proof.txt')),{code:'ENOENT'}); + attempt.checks.permissionBoundary=true; + attempt.status='passed'; + } catch(error) { attempt.status='failed'; attempt.errorCode=error.code??error.name; /* Raw errors and provider text stay out of this sanitized proof. */ } + finally { if(host) {try{await host.close({reason:'qualification cleanup'});}catch{attempt.status='failed';attempt.checks.cleanup=false;}} await rm(root,{recursive:true,force:true}); attempt.elapsedMs=Date.now()-start;await persist(); } + console.log(`Grok ${values.auth} repetition ${repetition}: ${attempt.status}`); +} +report.finishedAt=new Date().toISOString(); await persist(); +process.exitCode=report.attempts.every(attempt=>attempt.status==='passed')?0:1; diff --git a/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs b/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs index 5656395b1e4..2e6fe9ffedc 100644 --- a/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs +++ b/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.mjs @@ -62,6 +62,7 @@ export function credentialForConfig(config) { return "OPENAI_API_KEY"; } if (config.provider === "acpx") { + if (config.acpxAgent === "grok") return "XAI_API_KEY"; if (config.acpxAgent === "pi") return "OPENROUTER_API_KEY"; if (config.acpxAgent === "claude") return "ANTHROPIC_API_KEY"; if (config.acpxAgent === "codex") return "OPENAI_API_KEY"; diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index bf64c5c53c8..e2c069492e6 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -9,7 +9,7 @@ import type { AcpRuntimeEvent, } from "acpx/runtime"; -import { createAcpxToolEventNormalizer } from "../provider-events.js"; +import { createAcpxToolEventNormalizer, createGrokMessageNormalizer } from "../provider-events.js"; import { parseNativeRuntimeContext } from "../contracts/runtime-context.js"; import { PRP_BLOCK_TOOL_NAME, @@ -534,11 +534,13 @@ async function pumpTurn( // display metadata for later progress/completion frames before they cross // the sidecar boundary, matching the in-process ACPX driver path. const normalizeToolEvent = createAcpxToolEventNormalizer(); + const normalizeMessage = initializedAgent === "grok" + ? createGrokMessageNormalizer() : (event: AcpRuntimeEvent) => event; for await (const event of runtimeTurn.events) { emit( "runtime.event", sanitizeRuntimeEvent( - normalizeToolEvent(boundRuntimeEventForNormalization(event)), + normalizeMessage(normalizeToolEvent(boundRuntimeEventForNormalization(event))), ), currentTurnId, ); @@ -1199,8 +1201,8 @@ function requireHost( } function requireQualifiedAgent(value: unknown): QualifiedAcpxAgent { - if (value !== "codex" && value !== "claude") { - throw new Error("ACPX agent must be claude or codex"); + if (value !== "codex" && value !== "claude" && value !== "grok") { + throw new Error("ACPX agent must be claude, codex, or grok"); } return value; } diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts index 590d575e3eb..361a7d002d9 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts @@ -223,6 +223,13 @@ describe("eval-session request contract", () => { }); describe("eval-session usage", () => { + it("preserves Grok's missing usage as unknown instead of manufacturing zero tokens or cost", () => { + const parsed = { ...parseEvalSessionRequest(request()), provider: "acpx" as const, acpxAgent: "grok" as const }; + const turn = { turnId: "turn-1", status: "completed" as const, assistantText: "done", snapshot: { usageLedger: [] } as unknown as CapabilityLiveSessionSnapshot }; + expect(boundedEvalSessionUsage(parsed, turn)).toBeNull(); + expect(() => boundedEvalSessionUsage({ ...parsed, provider: "codex", acpxAgent: undefined }, turn)).toThrow(); + }); + it("retains durable failed turns even when their reported usage exceeds completed-turn limits", () => { const parsed = parseEvalSessionRequest(request()); const snapshot = { diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.ts b/packages/paperclip-runner/src/cli/eval-session-contract.ts index 9c48f987b97..5e64ef43fc2 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.ts @@ -241,9 +241,9 @@ export function parseEvalSessionRequest(value: unknown): EvalSessionRequest { if ( acpxAgent !== undefined && acpxAgent !== "codex" && - acpxAgent !== "claude" + acpxAgent !== "claude" && acpxAgent !== "grok" ) { - throw new Error("eval-session acpxAgent must be codex or claude"); + throw new Error("eval-session acpxAgent must be codex, claude, or grok"); } if (provider !== "acpx" && acpxAgent !== undefined) { throw new Error("eval-session acpxAgent requires provider acpx"); diff --git a/packages/paperclip-runner/src/cli/eval-session.ts b/packages/paperclip-runner/src/cli/eval-session.ts index c88c047920c..c35b8870dac 100644 --- a/packages/paperclip-runner/src/cli/eval-session.ts +++ b/packages/paperclip-runner/src/cli/eval-session.ts @@ -235,7 +235,8 @@ export function boundedEvalSessionUsage( request: EvalSessionRequest, turn: CapabilityLiveTurnResult, ): EvalSessionUsage | null { - if (turn.status !== "completed") { + if (turn.status !== "completed" || + (request.provider === "acpx" && request.acpxAgent === "grok" && !turn.snapshot.usageLedger?.length)) { return usageIfAvailable(request, turn.snapshot); } const usage = evalSessionUsage(request.model, turn.snapshot); diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index c23c2f9ab18..492ff842e8b 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -77,7 +77,7 @@ export interface NativeAwsAgentCoreProfileSnapshot { eventExpiryDays: 90; } -export type NativeAcpxAgent = "pi" | "claude" | "codex"; +export type NativeAcpxAgent = "pi" | "claude" | "codex" | "grok"; export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted"; export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny"; export type NativeAcpxPermissionMode = "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all"; @@ -532,8 +532,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput if (providerModel === null) { throw new NativeExecutionInputError("input.provider.model is required for acpx"); } - if (provider.agent !== "pi" && provider.agent !== "claude" && provider.agent !== "codex") { - throw new NativeExecutionInputError("input.provider.agent must be pi, claude, or codex"); + if (provider.agent !== "pi" && provider.agent !== "claude" && provider.agent !== "codex" && provider.agent !== "grok") { + throw new NativeExecutionInputError("input.provider.agent must be pi, claude, codex, or grok"); } if (isV4) { if (provider.permissionMode !== "approve-all" && provider.permissionMode !== "approve-paperclip" && provider.permissionMode !== "approve-reads" && provider.permissionMode !== "deny-all") { diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index 5729c4dded4..fd1c7714877 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -3283,6 +3283,8 @@ const runnerExplicitProviderEnvironmentKeys = [ "OPENAI_API_KEY", "CODEX_API_KEY", "PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET", + "PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET", + "XAI_API_KEY", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_WEB_IDENTITY_TOKEN_FILE", diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 6334a91fc6b..065b72d66d9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -42,6 +42,7 @@ import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.j import { canonicalProviderEventsFromAcpxRuntimeEvent, createAcpxToolEventNormalizer, + createGrokMessageNormalizer, } from "../../provider-events.js"; import { canonicalRunnerToolName, @@ -740,6 +741,7 @@ class CodexAcpxSession implements HarnessSession { } | null = null; #usage: Record | null = null; #assistantText = ""; + #assistantMessageId: string | null = null; #closed = false; #closingStarted = false; #eventStreamClosed = false; @@ -852,6 +854,7 @@ class CodexAcpxSession implements HarnessSession { const turnId = `turn-${randomBytes(12).toString("hex")}`; this.#activeTurnId = turnId; this.#assistantText = ""; + this.#assistantMessageId = null; this.#emit("turn.submitted", { text: input.message.text }, { turnId }); this.#emit("turn.accepted", { turnId }, { turnId }); this.#emit("turn.started", { status: "inProgress" }, { turnId }); @@ -1355,8 +1358,10 @@ class CodexAcpxSession implements HarnessSession { let index = 0; const normalizeToolEvent = createAcpxToolEventNormalizer(); + const normalizeMessage = this.#agent === "grok" + ? createGrokMessageNormalizer() : (event: AcpRuntimeEvent) => event; for await (const event of turn.events) { - this.#mapRuntimeEvent(normalizeToolEvent(event), turnId, ++index); + this.#mapRuntimeEvent(normalizeMessage(normalizeToolEvent(event)), turnId, ++index); } const result = await turn.result; this.#cancelPendingRuntimeRequests("provider turn settled", turnId); @@ -1541,6 +1546,9 @@ class CodexAcpxSession implements HarnessSession { const isReasoning = event.stream === "thought" || event.tag === "agent_thought_chunk"; if (!isReasoning) { + const messageId = typeof event.messageId === "string" && event.messageId ? event.messageId : null; + if (messageId && this.#assistantMessageId && messageId !== this.#assistantMessageId) this.#assistantText = ""; + if (messageId) this.#assistantMessageId = messageId; this.#assistantText = boundedText( `${this.#assistantText}${output}`, 256 * 1024, diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 1d237ed8d28..3ca58e36877 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -273,9 +273,9 @@ export async function openQualifiedAcpxRuntime( agentRegistry: createRegistry({ // Preserve Claude's ACP capability identity. This is metadata only: the // spawn callback below always launches the verified command lease. - overrides: { [options.profile.agent]: [options.profile.agent === "claude" - ? "/paperclip-verified/claude-agent-acp" - : VERIFIED_COMMAND_SENTINEL] }, + overrides: { [options.profile.agent]: options.profile.agent === "grok" + ? ["/paperclip-verified/grok", "agent", "stdio"] + : [options.profile.agent === "claude" ? "/paperclip-verified/claude-agent-acp" : VERIFIED_COMMAND_SENTINEL] }, }), // ACPX does not know the Paperclip-specific mode. Exact SDK rules allow // admitted actions; all remaining requests keep its closed read policy. diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts index 7fe49dcbac1..559be9b3494 100644 --- a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts @@ -12,7 +12,7 @@ import { type QualifiedAcpxAgent, } from "./qualified-profiles.js"; -const ACPX_AGENTS = ["claude", "codex"] as const; +const ACPX_AGENTS = ["claude", "codex", "grok"] as const; const ACPX_PERMISSION_MODES = [ "approve-all", "approve-paperclip", @@ -37,8 +37,8 @@ export function acpxCapabilities( plan: agent === "pi" ? "unsupported" : "available", tool_execution: "available", model_identity: "available", - review: "available", - provider_notice: "available", + review: agent === "grok" ? "unsupported" : "available", + provider_notice: agent === "grok" ? "unsupported" : "available", artifact: "policy_disabled", }), steering: false, @@ -46,7 +46,7 @@ export function acpxCapabilities( structuredResult: true, read: true, reconciliation: true, - usage: true, + usage: agent !== "grok", dynamicTools: true, runtimeRequestResolution: true, runtimeRequestHandoff: true, @@ -96,7 +96,7 @@ export function validateAcpxDriverConfig( return invalid( "agent", "invalid_agent", - "ACPX agent must be claude or codex.", + "ACPX agent must be claude, codex, or grok.", ); } const model = text(config.model); @@ -144,6 +144,7 @@ function isPermissionMode(value: string): value is NativeAcpxPermissionMode { } function displayAgent(agent: QualifiedAcpxAgent): string { + if (agent === "grok") return "Grok Build"; if (agent === "pi") return "Pi"; if (agent === "claude") return "Claude"; return "Codex"; diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index 57fef1b4e3c..0e1ff50b80a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -25,7 +25,9 @@ export function createSanitizedAcpxSpawnInput( const source = environment ?? process.env; const result: NodeJS.ProcessEnv = {}; const credentialNames = - agent === "pi" + agent === "grok" + ? ["XAI_API_KEY"] + : agent === "pi" ? ["OPENROUTER_API_KEY"] : agent === "claude" ? ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] diff --git a/packages/paperclip-runner/src/drivers/acpx/grok-credentials.test.ts b/packages/paperclip-runner/src/drivers/acpx/grok-credentials.test.ts new file mode 100644 index 00000000000..8c5609b66b0 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/grok-credentials.test.ts @@ -0,0 +1,81 @@ +import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { stageManagedGrokCredential } from "./grok-credentials.js"; +import { createSanitizedAcpxSpawnInput } from "./environment.js"; +import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; +import { requireVerifiedAcpxModel } from "./model-verification.js"; +import { decideAcpxPermission } from "./permission-policy.js"; + +const roots: string[] = []; +const credential = JSON.stringify({ "https://accounts.x.ai::11111111-1111-4111-8111-111111111111": { key: "test-token", refresh_token: "test-refresh" } }); +async function home() { const path = await mkdtemp(join(tmpdir(), "grok-credential-test-")); roots.push(path); return path; } +afterEach(async () => { await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))); }); + +describe("Grok isolated credentials", () => { + it.each([{}, { PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: "{}" }, { PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: "not-json" }, { PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: credential, XAI_API_KEY: "test-key" }])("rejects missing, malformed and ambiguous sources", async environment => { + await expect(stageManagedGrokCredential({ agentHomeDirectory: await home(), environment })).rejects.toThrow(/required|malformed|ambiguous/); + }); + it("stages only subscription auth privately and retains refreshed auth until controller copyback", async () => { + const directory = await home(); + const lease = await stageManagedGrokCredential({ agentHomeDirectory: directory, environment: { PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: credential } }); + expect((await stat(join(directory, "auth.json"))).mode & 0o777).toBe(0o600); + expect(await readFile(join(directory, "auth.json"), "utf8")).toBe(credential); + const refreshed = credential.replace("test-token", "refreshed-token"); + await writeFile(join(directory, "auth.json"), refreshed, { mode: 0o600 }); + await Promise.all([lease.close(), lease.close()]); + await expect(readFile(join(directory, "auth.json"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(join(directory, "auth-refresh.json"), "utf8")).toBe(refreshed); + await lease.close(); + }); + it("does not stage subscription credentials for an explicit API key", async () => { + const directory = await home(); + const lease = await stageManagedGrokCredential({ agentHomeDirectory: directory, environment: { XAI_API_KEY: "test-key" } }); + await expect(readFile(join(directory, "auth.json"))).rejects.toMatchObject({ code: "ENOENT" }); + await lease.close(); + await expect(readFile(join(directory, "auth-refresh.json"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("fences concurrent refresh owners and removes a stale refresh before the next turn", async () => { + const directory = await home(); + const options = { agentHomeDirectory: directory, environment: { PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: credential } }; + const first = await stageManagedGrokCredential(options); + await expect(stageManagedGrokCredential(options)).rejects.toThrow(/active lease|owned/); + await first.close(); + const second = await stageManagedGrokCredential(options); + await expect(readFile(join(directory, "auth-refresh.json"))).rejects.toMatchObject({ code: "ENOENT" }); + await second.close(); + }); + it("rejects a substituted refresh symlink without releasing ownership or copying its target", async () => { + const directory = await home(); + const options = { agentHomeDirectory: directory, environment: { PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: credential } }; + const lease = await stageManagedGrokCredential(options); + const target = join(await home(), "private.json"); + await writeFile(target, "unrelated", { mode: 0o600 }); + await rm(join(directory, "auth.json")); + await symlink(target, join(directory, "auth.json")); + await expect(lease.close()).rejects.toThrow(); + await expect(stageManagedGrokCredential(options)).rejects.toThrow(/active lease|owned/); + expect(await readFile(target, "utf8")).toBe("unrelated"); + await rm(join(directory, "auth.json")); + await writeFile(join(directory, "auth.json"), credential, { mode: 0o600 }); + await lease.close(); + }); + it("never passes other providers, host homes or the inline credential to Grok", () => { + const result = createSanitizedAcpxSpawnInput({ XAI_API_KEY: "selected", HOME: "/host", GROK_HOME: "/host/.grok", OPENAI_API_KEY: "other", PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: credential }, "grok"); + expect(result.env).toEqual({ XAI_API_KEY: "selected" }); + }); + it("cannot pass model verification with a missing or mismatched model", async () => { + const profile = resolveQualifiedAcpxProfile("grok", "grok-4.7"); + for (const status of [{}, { models: { currentModelId: "grok-other" } }]) { + await expect(requireVerifiedAcpxModel({ getStatus: async () => status, setModel: async () => {} }, profile)).rejects.toMatchObject({ code: "ACPX_EFFECTIVE_MODEL_MISMATCH" }); + } + }); + it("keeps untrusted read hints and Paperclip-looking tools behind the selected approval policy", () => { + const request = { inferredKind: "read", raw: { toolCall: { title: "mcp__paperclip__finish_task" } } }; + expect(decideAcpxPermission("grok", "approve-reads", request)).toBe("delegate"); + expect(decideAcpxPermission("grok", "approve-paperclip", request)).toBe("delegate"); + expect(decideAcpxPermission("grok", "deny-all", request)).toBe("reject_once"); + expect(decideAcpxPermission("grok", "approve-all", request)).toBe("allow_once"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/grok-credentials.ts b/packages/paperclip-runner/src/drivers/acpx/grok-credentials.ts new file mode 100644 index 00000000000..a54a0681983 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/grok-credentials.ts @@ -0,0 +1,87 @@ +import { constants } from "node:fs"; +import { open, rename, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { stageManagedCodexCredential, type AcpxProviderLifetimeLease } from "./codex-credentials.js"; + +export const GROK_AUTH_REFRESH_FILE = "auth-refresh.json"; + +/** Shares the established credential staging, crash cleanup and process fence. + * The Grok document uses the same auth.json filename, but its own source and + * validation. Never forward the controller's inline credential to the provider. + */ +export async function stageManagedGrokCredential(input: { + agentHomeDirectory: string; + environment?: NodeJS.ProcessEnv; +}): Promise { + const source = input.environment ?? {}; + const inline = source.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET; + const apiKey = source.XAI_API_KEY; + if (inline && apiKey) throw new Error("Grok credential source is ambiguous"); + if (!inline && !apiKey) throw new Error("Grok login or explicit XAI_API_KEY is required"); + if (inline) { + if (Buffer.byteLength(inline) > 256 * 1024) throw new Error("Grok credential exceeds its bound"); + let value: unknown; + try { value = JSON.parse(inline); } catch { throw new Error("Grok credential is malformed"); } + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Grok credential is malformed"); + const entries = Object.entries(value); + // Keep aligned with grok-local's parseGrokAuthPayload / hasUsableGrokAuthValue. + const [identity, auth] = entries[0] ?? []; + if (entries.length !== 1 || !/^.+::[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identity ?? "") || + !auth || typeof auth.key !== "string" || !auth.key.trim() || + typeof auth.refresh_token !== "string" || !auth.refresh_token.trim()) { + throw new Error("Grok credential is malformed"); + } + } + const lease = await stageManagedCodexCredential({ + agentHomeDirectory: input.agentHomeDirectory, + environment: inline ? { PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: inline } : { OPENAI_API_KEY: apiKey }, + }); + const refresh = join(input.agentHomeDirectory, GROK_AUTH_REFRESH_FILE); + await rm(refresh, { force: true }).catch(async (error) => { await lease.close(); throw error; }); + let closed = false; + let closing: Promise | null = null; + return { + lifetimeFenceCandidates: lease.lifetimeFenceCandidates, + lifetimeFenceFds: lease.lifetimeFenceFds, + activateLifetimeOwner: (pid) => lease.activateLifetimeOwner(pid), + close() { + if (closed) return Promise.resolve(); + if (closing) return closing; + closing = (async () => { + // The runtime host calls close only after verified provider exit. Retain + // a private refresh handoff for the controller's existing Grok merge + // predicate, then scrub auth.json before releasing the process fence. + if (inline) { + const auth = await open(join(input.agentHomeDirectory, "auth.json"), constants.O_RDONLY | constants.O_NOFOLLOW).catch((error) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (auth) { + try { + const stat = await auth.stat(); + if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600 || stat.size > 256 * 1024) throw new Error("Grok refreshed credential is invalid"); + const buffer = Buffer.alloc(256 * 1024 + 1); + let size = 0; + while (size < buffer.length) { + const read = await auth.read(buffer, size, buffer.length - size, size); + if (!read.bytesRead) break; + size += read.bytesRead; + } + if (size > 256 * 1024) { buffer.fill(0); throw new Error("Grok refreshed credential exceeds its bound"); } + const bytes = buffer.subarray(0, size); + const temporary = `${refresh}.tmp`; + try { + await rm(temporary, { force: true }); + await writeFile(temporary, bytes, { flag: "wx", mode: 0o600 }); + await rename(temporary, refresh); + } finally { bytes.fill(0); await rm(temporary, { force: true }); } + } finally { await auth.close(); } + } + } + await lease.close(); + closed = true; + })().finally(() => { closing = null; }); + return closing; + }, + }; +} diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts index 132b87403a9..c1bf3c715df 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -605,6 +605,20 @@ describe("ACPX installation integrity", () => { }, ); + it("rejects a tampered native Grok executable and symlink substitution", async () => { + const fixture = await installationFixture(); + const base = resolveQualifiedAcpxProfile("grok", "grok-4.7"); + const profile = { ...base, commandDigest: fixture.profile.commandDigest }; + await writeFile(fixture.serverPackageJsonPath, JSON.stringify({ version: "1.0.13", bin: "bin/server.js" })); + const native = join(fixture.commandDirectory, "grok"); + await writeFile(native, "tampered native binary", { mode: 0o700 }); + const resolvePackage = () => fixture.serverPackageJsonPath; + await expect(verifyQualifiedAcpxInstallation(profile, resolvePackage)).rejects.toThrow(/digest mismatch/); + await rm(native); + await symlink(fixture.commandPath, native); + await expect(verifyQualifiedAcpxInstallation(profile, resolvePackage)).rejects.toThrow(/regular file|symlink|no-follow/); + }); + it("rejects package version and executable digest drift", async () => { const fixture = await installationFixture(); await writeFile( diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index e65d90c947e..110887e66da 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -342,7 +342,13 @@ export function createAcpxPackageJsonResolver( const packageJsonPath = realpathSync( resolvePackageJsonFromIssuer(packageName, canonicalIssuer), ); - if (!pathIsInside(canonicalNodeModules, packageJsonPath)) { + // The native Grok launcher is a source-owned workspace package. Admit + // only its exact source location when the issuer is the source Runner; + // installed packs still resolve exclusively below their node_modules. + const sourceGrokPackage = packageName === "@paperclipai/grok-acp" && + canonicalManifest === resolve(canonicalRoot, "packages/paperclip-runner/package.json") && + packageJsonPath === resolve(canonicalRoot, "packages/grok-acp/package.json"); + if (!pathIsInside(canonicalNodeModules, packageJsonPath) && !sourceGrokPackage) { throw new Error( `ACPX provider package ${packageName} resolves outside the selected provider root`, ); @@ -490,7 +496,7 @@ interface VerifiedAcpxRuntimeExecutable { path: string; digest: string; identity: VerifiedAcpxCommandIdentity; - environmentVariable: "CLAUDE_CODE_EXECUTABLE" | "CODEX_PATH"; + environmentVariable: "CLAUDE_CODE_EXECUTABLE" | "CODEX_PATH" | "PAPERCLIP_GROK_VERIFIED_EXECUTABLE"; } interface AcpxPackageMetadata { @@ -838,6 +844,19 @@ async function verifyQualifiedRuntimeExecutable(input: { runtimePackageJsonPath: string; resolvePackageJson: AcpxPackageJsonResolver; }): Promise { + if (input.profile.agent === "grok") { + const digests: Record = { + "darwin-arm64": "8669e0fdadceec25b8c159c355f427ffbd82583525d774b6ab1522197ea83b80", + "linux-x64": "edf79521581bb5e6b95abef848491a6a742e860da3e237ebe86a280d30dce4c1", + }; + const digest = digests[`${process.platform}-${process.arch}`]; + if (!digest) throw new Error(`Grok verified runtime unavailable for ${process.platform}-${process.arch}`); + const executablePath = resolve(dirname(input.runtimePackageJsonPath), "bin/grok"); + const verified = await openVerifiedRuntimeExecutable(executablePath, `sha256:${digest}`, "grok"); + await verified.handle.close(); + return { path: executablePath, digest: `sha256:${digest}`, identity: verified.identity, + environmentVariable: "PAPERCLIP_GROK_VERIFIED_EXECUTABLE" }; + } const qualification = input.profile.agent === "claude" ? process.platform === "darwin" && (process.arch === "arm64" || process.arch === "x64") @@ -1689,7 +1708,7 @@ function snapshotBootstrap(format: AcpxCommandFormat, guarded = false): string { 'if ((serverPackageFormat !== "module" && serverPackageFormat !== "commonjs") || !Array.isArray(dependencyAncestorFormats) || dependencyAncestorFormats.length !== dependencyAncestorCount || dependencyAncestorFormats.some((value) => value !== "module" && value !== "commonjs")) throw new Error("ACPX provider package formats are invalid");', 'if (providerRuntimeExecutableCount !== 0 && providerRuntimeExecutableCount !== 1) throw new Error("ACPX provider runtime executable count is invalid");', `const providerRuntimeExecutableFd = ${DEPENDENCY_ANCESTOR_FD_START} + dependencyAncestorCount;`, - 'if (providerRuntimeExecutableCount === 1) { if (providerRuntimeEnvironmentVariable !== "CODEX_PATH" && providerRuntimeEnvironmentVariable !== "CLAUDE_CODE_EXECUTABLE") throw new Error("ACPX provider runtime environment target is invalid"); fs.fstatSync(providerRuntimeExecutableFd); process.env[providerRuntimeEnvironmentVariable] = privateSnapshot ? privateSnapshot.executable : "/proc/" + process.pid + "/fd/" + providerRuntimeExecutableFd; } else if (providerRuntimeEnvironmentVariable !== undefined) throw new Error("ACPX provider runtime environment target is unexpected");', + 'if (providerRuntimeExecutableCount === 1) { if (providerRuntimeEnvironmentVariable !== "CODEX_PATH" && providerRuntimeEnvironmentVariable !== "CLAUDE_CODE_EXECUTABLE" && providerRuntimeEnvironmentVariable !== "PAPERCLIP_GROK_VERIFIED_EXECUTABLE") throw new Error("ACPX provider runtime environment target is invalid"); fs.fstatSync(providerRuntimeExecutableFd); process.env[providerRuntimeEnvironmentVariable] = privateSnapshot ? privateSnapshot.executable : "/proc/" + process.pid + "/fd/" + providerRuntimeExecutableFd; } else if (providerRuntimeEnvironmentVariable !== undefined) throw new Error("ACPX provider runtime environment target is unexpected");', ...(guarded ? [ `const guardianFd = ${DEPENDENCY_ANCESTOR_FD_START} + dependencyAncestorCount + providerRuntimeExecutableCount;`, @@ -2019,3 +2038,7 @@ export async function probeAcpxClaudeInstallation(model: string): Promise const lease = await installation.openCommand(); await lease.close(); } + +export async function probeAcpxGrokInstallation(model: string): Promise { + await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile("grok", model)); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/private-snapshot.ts b/packages/paperclip-runner/src/drivers/acpx/private-snapshot.ts index 65076c8f2e2..87208fe70d8 100644 --- a/packages/paperclip-runner/src/drivers/acpx/private-snapshot.ts +++ b/packages/paperclip-runner/src/drivers/acpx/private-snapshot.ts @@ -55,6 +55,7 @@ export async function createAcpxPrivateSnapshot( executable: FileHandle | null, ): Promise { sourceRoots = await Promise.all(sourceRoots.map((root) => realpath(root))); + const executableIdentity = executable ? await executable.stat({ bigint: true }) : null; const sourceIdentities = await Promise.all( sourceRoots.map((root) => lstat(root, { bigint: true })), ); @@ -116,6 +117,8 @@ export async function createAcpxPrivateSnapshot( throw new Error("ACPX package directory changed during snapshot"); return; } + // The separately pinned native binary is copied below with its own bound. + if (before.isFile() && executableIdentity && before.dev === executableIdentity.dev && before.ino === executableIdentity.ino) return; if (!before.isFile() || before.size > 16n * 1024n * 1024n) throw new Error("ACPX module must be a bounded regular file"); bytesCopied += Number(before.size); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index ee54598b11e..2abffedb1d6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -31,6 +31,14 @@ export interface QualifiedAcpxProfile { export const QUALIFIED_ACPX_PROFILES: Readonly< Record > = deepFreeze({ + grok: { + driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, + acpxVersion: QUALIFIED_ACPX_VERSION, agent: "grok", agentProfileVersion: 1, + agentServerPackage: "@paperclipai/grok-acp", agentServerVersion: "1.0.13", + agentRuntimePackage: "@paperclipai/grok-acp", agentRuntimeVersion: "1.0.13", + commandDigest: "sha256:42fe296ec6fc0715c3509cec9671451bcd5bfdc7f185041101c8aac1e9ac8718", + qualificationModel: "grok-4.7", reportedModelId: "grok-4.7", permissionPolicy: "interactive", + }, pi: { driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, @@ -87,7 +95,7 @@ export function resolveQualifiedAcpxProfile( ): QualifiedAcpxProfile { const profile = QUALIFIED_ACPX_PROFILES[agent]; if (!requestedModel.trim()) throw new Error("ACPX model must not be empty"); - if (agent !== "claude" && requestedModel !== profile.qualificationModel) { + if (agent !== "claude" && agent !== "grok" && requestedModel !== profile.qualificationModel) { throw new Error( `ACPX ${agent} profile requires exact model ${profile.qualificationModel}; received ${requestedModel}`, ); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index efc09052a5b..8b4e9ea1026 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -1,3 +1,4 @@ +import { stageManagedGrokCredential } from "./grok-credentials.js"; import { join } from "node:path"; import { claudeNativeSkillPrompt } from "./native-skill-prompt.js"; import { nativeMcpLaunchBinding } from "../native-mcp.js"; @@ -404,6 +405,13 @@ export class AcpxRuntimeHost { reportFailure: (failure) => dependencies.reportRetainedCleanupFailure(failure), }); + } else if (options.agent === "grok") { + credential = await acquireAbortableAdmissionResource({ + signal: options.signal, + acquire: () => stageManagedGrokCredential({ agentHomeDirectory: sandbox.agentHomeDirectory, environment: options.environment }), + resource: "credential", releaseLate: (lease) => lease.close(), + reportFailure: (failure) => dependencies.reportRetainedCleanupFailure(failure), + }); } else { credential = await acquireAbortableAdmissionResource({ signal: options.signal, @@ -417,7 +425,7 @@ export class AcpxRuntimeHost { dependencies.reportRetainedCleanupFailure(failure), }); } - if (options.agent === "claude") { + if (options.agent === "claude" || options.agent === "grok") { // The lifetime lease proves the previous provider has stopped. Refresh // the assigned snapshot before every launch, including durable resume; // Claude discovers user skills beneath its isolated CLAUDE_CONFIG_DIR. diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 30d2f4cbb79..012ea5b6ec0 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -428,6 +428,7 @@ export async function prepareAcpxRuntimeSandbox(input: { XDG_CACHE_HOME: cacheDirectory, PAPERCLIP_ACPX_PROFILE: input.agent, PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1", + ...(input.agent === "grok" ? { GROK_HOME: agentHomeDirectory, NO_BROWSER: "1" } : {}), ...(input.agent === "pi" ? { PI_CODING_AGENT_DIR: agentHomeDirectory, diff --git a/packages/paperclip-runner/src/eval/live-workflow-executor.ts b/packages/paperclip-runner/src/eval/live-workflow-executor.ts index 7d81d5ef4fc..561aa6ffae1 100644 --- a/packages/paperclip-runner/src/eval/live-workflow-executor.ts +++ b/packages/paperclip-runner/src/eval/live-workflow-executor.ts @@ -339,8 +339,9 @@ async function restoreAfterDelegatedChild(input: { function acpxAgent( candidate: RunnerLiveEvalCandidate, -): "claude" | "codex" | undefined { +): "claude" | "codex" | "grok" | undefined { if (candidate.adapter !== "acpx_runtime") return undefined; + if (candidate.qualification.profile === "grok") return "grok"; if (candidate.qualification.profile === "claude") return "claude"; if (candidate.qualification.profile === "codex") return "codex"; return undefined; diff --git a/packages/paperclip-runner/src/live/index.ts b/packages/paperclip-runner/src/live/index.ts index c74c4ef5b18..22b2bc5e375 100644 --- a/packages/paperclip-runner/src/live/index.ts +++ b/packages/paperclip-runner/src/live/index.ts @@ -5,4 +5,4 @@ export * from "./durable-live-session-store.js"; export * from "./runnerd-codex-transport.js"; export * from "./turn-stream.js"; -export { probeAcpxClaudeInstallation } from "../drivers/acpx/installation-integrity.js"; +export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation } from "../drivers/acpx/installation-integrity.js"; diff --git a/packages/paperclip-runner/src/live/live-session.ts b/packages/paperclip-runner/src/live/live-session.ts index 821352e71f2..31e1782ebd2 100644 --- a/packages/paperclip-runner/src/live/live-session.ts +++ b/packages/paperclip-runner/src/live/live-session.ts @@ -1,3 +1,4 @@ +import { liveRunResultFeedback } from "./run-result-feedback.js"; import { createHash, randomUUID } from "node:crypto"; import { resolve } from "node:path"; @@ -628,7 +629,7 @@ export function assertCapabilityLiveSessionSnapshot( } if (provider === "acpx") { const agent = config.acpxAgent; - if (agent !== "pi" && agent !== "claude" && agent !== "codex") { + if (agent !== "pi" && agent !== "claude" && agent !== "codex" && agent !== "grok") { throw new Error("capability_live_checkpoint_corrupt: invalid config.acpxAgent"); } const expected = resolveQualifiedAcpxProfile(agent, text(config.requestedModel)); @@ -1659,7 +1660,8 @@ export class CapabilityLiveSession { } await this.#captureTurnUsage( result.turnId, - result.status !== "completed" || options.allowMissingUsage === true, + result.status !== "completed" || options.allowMissingUsage === true || + (this.#config.provider === "acpx" && this.#config.acpxAgent === "grok"), ); await this.#persist(); await this.#afterTurnSettled(); @@ -1783,6 +1785,10 @@ export class CapabilityLiveSession { totalKeys: Object.keys(total).sort(), })}`); } + // Grok 1.0.13 does not report verified token/cost measurements. Do not + // synthesize a zero receipt from the fallback when nothing was observed. + if (this.#config.provider === "acpx" && this.#config.acpxAgent === "grok" && + (captured?.reported ?? captured?.raw ?? captured?.terminal) == null && Object.keys(total).length === 0) return; const finalUsage = selectedWithReportedCost ?? cumulativeFallback; await this.recordUsage({ receiptId: `${turnId}:usage`, @@ -2389,7 +2395,8 @@ export class CapabilityLiveSession { permissions: CODEX_PERMISSION_PROFILE, runtimeWorkspaceRoots: [this.#config.workingDirectory], baseInstructions: - this.#transportOptions.baseInstructions ?? LIVE_BASE_INSTRUCTIONS, + [this.#transportOptions.baseInstructions ?? LIVE_BASE_INSTRUCTIONS, + `For native paperclip_finish/paperclip_block reports, completionContract=${JSON.stringify(LIVE_COMPLETION_CONTRACT)}. This report does not change mock task state; use the exposed semantic tools for task changes.`].join("\n"), persistExtendedHistory: true, }); const resumedThread = record(resumed.thread); @@ -2415,7 +2422,8 @@ export class CapabilityLiveSession { runtimeWorkspaceRoots: [this.#config.workingDirectory], approvalPolicy: "never", baseInstructions: - this.#transportOptions.baseInstructions ?? LIVE_BASE_INSTRUCTIONS, + [this.#transportOptions.baseInstructions ?? LIVE_BASE_INSTRUCTIONS, + `For native paperclip_finish/paperclip_block reports, completionContract=${JSON.stringify(LIVE_COMPLETION_CONTRACT)}. This report does not change mock task state; use the exposed semantic tools for task changes.`].join("\n"), completionContract: LIVE_COMPLETION_CONTRACT, dynamicTools: [ ...tools.map(dynamicToolSpec), @@ -2511,6 +2519,13 @@ export class CapabilityLiveSession { contentItems: [{ type: "inputText", text: "Tool call was outside the active Capability thread and turn." }], }; } + const runResult = liveRunResultFeedback(operationId, request.params.arguments, LIVE_COMPLETION_CONTRACT.revision); + if (runResult) { + this.#appendEvidence("tool_call", turnId, { callId, operationId, input: jsonValue(request.params.arguments), beforeRevision: this.#port.snapshot().revision }); + this.#appendEvidence("tool_result", turnId, { callId, operationId, result: jsonValue(runResult), beforeRevision: this.#port.snapshot().revision, afterRevision: this.#port.snapshot().revision }); + await this.#persist(); + return this.#codexToolResponse(runResult); + } if (operationId === DISCOVER_TOOL) { const args = record(request.params.arguments); try { diff --git a/packages/paperclip-runner/src/live/run-result-feedback.test.ts b/packages/paperclip-runner/src/live/run-result-feedback.test.ts new file mode 100644 index 00000000000..af6331d3da7 --- /dev/null +++ b/packages/paperclip-runner/src/live/run-result-feedback.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { liveRunResultFeedback } from "./run-result-feedback.js"; +const result = { + reportedWorkDisposition: "done", summary: "Reported completion", + completionClaim: { contractRevision: "revision-1", objectiveSatisfied: true, criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] }, + evidence: [], verification: [], attentionRequests: [], artifacts: [], +}; +describe("live run-result feedback", () => { + it("acknowledges a valid native run report without pretending to mutate the task", () => { + expect(liveRunResultFeedback("paperclip_finish", result, "revision-1")).toEqual({ ok: true, result: { reportedWorkDisposition: "done", accepted: true, taskStateChanged: false } }); + }); + it.each([null, {}, { ...result, completionClaim: {} }, { ...result, completionClaim: { ...result.completionClaim, criteria: [] } }])("rejects missing or malformed evidence", value => { + expect(liveRunResultFeedback("paperclip_finish", value, "revision-1")?.ok).toBe(false); + }); + it("rejects stale revisions and mismatched terminal operations", () => { + expect(liveRunResultFeedback("paperclip_finish", result, "revision-2")?.ok).toBe(false); + expect(liveRunResultFeedback("paperclip_block", result, "revision-1")?.ok).toBe(false); + expect(liveRunResultFeedback("finish_task", result, "revision-1")).toBeNull(); + }); +}); diff --git a/packages/paperclip-runner/src/live/run-result-feedback.ts b/packages/paperclip-runner/src/live/run-result-feedback.ts new file mode 100644 index 00000000000..5d7f44a1bae --- /dev/null +++ b/packages/paperclip-runner/src/live/run-result-feedback.ts @@ -0,0 +1,18 @@ +import { PRP_BLOCK_TOOL_NAME, PRP_COMPLETION_TOOL_NAME } from "../contracts/completion-result.js"; +import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js"; + +/** Accept a provider run report without mutating or grading the mock task. */ +export function liveRunResultFeedback(operation: string, value: unknown, revision: string) { + if (operation !== PRP_COMPLETION_TOOL_NAME && operation !== PRP_BLOCK_TOOL_NAME) return null; + const parsed = validatePrpStructuredRunResult(value); + if (!parsed.ok) return { ok: false, denial: { code: "invalid_run_result", message: "Run result does not satisfy the completion schema." } }; + const result = parsed.result; + if ((operation === PRP_BLOCK_TOOL_NAME) !== (result.reportedWorkDisposition === "blocked")) { + return { ok: false, denial: { code: "invalid_run_result", message: "Run disposition does not match the selected tool." } }; + } + if (result.completionClaim.contractRevision !== revision || + result.completionClaim.criteria.length !== 1 || result.completionClaim.criteria[0]?.criterionId !== "objective") { + return { ok: false, denial: { code: "stale_completion_contract", message: `Use completion contract ${revision} with criterion objective.` } }; + } + return { ok: true, result: { reportedWorkDisposition: result.reportedWorkDisposition, accepted: true, taskStateChanged: false } }; +} diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 4b624890030..4d23eb9f94e 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3110,6 +3110,8 @@ export function createCapabilityRunnerdProviderEnvironment(input: { input.options.environment, input.options.acpxAgent ?? "codex", ).env, + ...(input.options.acpxAgent === "grok" && input.options.environment?.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET + ? { PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: input.options.environment.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET } : {}), ...commonIdentity, // The verified sidecar bundle cannot use import.meta.url while Node // executes it through /proc/self/fd. Anchor its closed provider package @@ -4723,7 +4725,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { : provider === "acpx" ? acpxAgent === "pi" ? "openrouter" - : acpxAgent === "claude" + : acpxAgent === "grok" ? "xai" : acpxAgent === "claude" ? "anthropic" : "openai" : "openai", diff --git a/packages/paperclip-runner/src/provider-events.test.ts b/packages/paperclip-runner/src/provider-events.test.ts index 570b69df855..77b5fb51201 100644 --- a/packages/paperclip-runner/src/provider-events.test.ts +++ b/packages/paperclip-runner/src/provider-events.test.ts @@ -7,6 +7,7 @@ import { canonicalProviderEventsFromCodex, canonicalProviderEventsFromOpenCodePart, createAcpxToolEventNormalizer, + createGrokMessageNormalizer, providerFamilyCapabilities, } from "./provider-events.js"; import { validatePrpEvent } from "./protocol/replay-contract.js"; @@ -759,3 +760,26 @@ describe("provider-neutral events", () => { ).toMatchObject({ ok: false }); }); }); + + +describe("Grok output message boundaries", () => { + it("keeps streamed chunks together and separates commentary from the post-tool answer", () => { + const normalize = createGrokMessageNormalizer(); + expect(normalize({ type: "text_delta", text: "I will " }).messageId).toBe("grok-output-0"); + expect(normalize({ type: "text_delta", text: "check." }).messageId).toBe("grok-output-0"); + normalize({ type: "tool_call", toolCallId: "tool", status: "pending" }); + normalize({ type: "tool_call", toolCallId: "tool", status: "completed" }); + const thought = { type: "text_delta", stream: "thought", text: "private" }; + expect(normalize(thought)).toBe(thought); + expect(normalize({ type: "text_delta", text: "Final" }).messageId).toBe("grok-output-1"); + expect(normalize({ type: "text_delta", text: " answer" }).messageId).toBe("grok-output-1"); + }); + it("preserves native IDs and does not invent boundaries for status updates", () => { + const normalize = createGrokMessageNormalizer(); + expect(normalize({ type: "text_delta", messageId: "native-id", text: "hi" }).messageId).toBe("native-id"); + normalize({ type: "status", text: "working" }); + expect(normalize({ type: "text_delta", messageId: "native-id", text: " there" }).messageId).toBe("native-id"); + const reasoning = { type: "text_delta", tag: "agent_thought_chunk", text: "private" }; + expect(normalize(reasoning)).toBe(reasoning); + }); +}); diff --git a/packages/paperclip-runner/src/provider-events.ts b/packages/paperclip-runner/src/provider-events.ts index dda2b049627..95a35098f25 100644 --- a/packages/paperclip-runner/src/provider-events.ts +++ b/packages/paperclip-runner/src/provider-events.ts @@ -245,6 +245,23 @@ function boundedAcpxLifecycleLocations(value: unknown): unknown[] | undefined { * title as the literal `tool call`, so consumers must restore lifecycle * identity before translating the event into a durable protocol record. */ +/** Grok omits message IDs. A tool boundary ends its preceding output segment. + * Thoughts never become output and tool progress cannot fragment output chunks. + * Create one normalizer per turn; native IDs, when present, remain authoritative. + */ +export function createGrokMessageNormalizer(): (event: T) => T { + let segment = 0; + let hasOutput = false; + let boundary = false; + return (event) => { + if (event.type === "tool_call" && hasOutput) boundary = true; + if (event.type !== "text_delta" || event.stream === "thought" || event.tag === "agent_thought_chunk") return event; + if (boundary) { segment += 1; boundary = false; } + hasOutput = true; + return { ...event, messageId: event.messageId || `grok-output-${segment}` }; + }; +} + export function createAcpxToolEventNormalizer< T extends AcpRuntimeEventShape, >(): (event: T) => T { diff --git a/packages/shared/src/ai-connections.ts b/packages/shared/src/ai-connections.ts index 663dac36676..496d3744567 100644 --- a/packages/shared/src/ai-connections.ts +++ b/packages/shared/src/ai-connections.ts @@ -120,6 +120,8 @@ export function isAiConnectionCompatible( runnerProvider === "claude" || (runnerProvider === "acpx" && acpxAgent === "claude") ? "claude_local" + : runnerProvider === "acpx" && acpxAgent === "grok" + ? "grok_local" : runnerProvider === "codex" ? "codex_local" : runnerProvider === "opencode" diff --git a/patches/acpx@0.13.1.patch b/patches/acpx@0.13.1.patch index 57e1cfe435b..7c8350f1e2c 100644 --- a/patches/acpx@0.13.1.patch +++ b/patches/acpx@0.13.1.patch @@ -1,5 +1,5 @@ diff --git a/dist/client-CxNllqui.d.ts b/dist/client-CxNllqui.d.ts -index 5e2113ac0a1a92c99322cf01e5c106760a0b0dbf..b7b5151f3da45b5e0d12aea55e9f3b071ab54a8d 100644 +index 5e2113a..b7b5151 100644 --- a/dist/client-CxNllqui.d.ts +++ b/dist/client-CxNllqui.d.ts @@ -135,6 +135,7 @@ declare class AcpClient { @@ -11,7 +11,7 @@ index 5e2113ac0a1a92c99322cf01e5c106760a0b0dbf..b7b5151f3da45b5e0d12aea55e9f3b07 private setSessionModelThroughConfig; private setSessionModelThroughLegacyMethod; diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js -index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e930a933d259e79a6b03b1148aa58d686b781129 100644 +index d454fd7..991d21d 100644 --- a/dist/live-checkpoint-BSIrfgVo.js +++ b/dist/live-checkpoint-BSIrfgVo.js @@ -1068,6 +1068,7 @@ function serializeSessionRecordForDisk(record) { @@ -39,6 +39,19 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e930a933d259e79a6b03b1148aa58d68 const OPAQUE_VALUE_PATHS = /* @__PURE__ */ new Set([ "agent_capabilities", "messages.Agent.content.ToolUse.input", +@@ -3104,10 +3106,10 @@ function authEnvKey(methodId) { + authEnvKeyCache.set(methodId, key); + return key; + } +-function readEnvCredential(methodId) { ++function readEnvCredential(methodId, environment = process.env) { + const key = authEnvKey(methodId); + if (!key) return; +- const value = process.env[key]; ++ const value = environment[key]; + if (typeof value === "string" && value.trim().length > 0) return value; + } + function protectedEnvKey(key) { @@ -3135,8 +3137,18 @@ function promotePrefixedAuthEnvironment(env) { } return protectedKeys; @@ -145,7 +158,37 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e930a933d259e79a6b03b1148aa58d68 try { await waitForSpawn$1(spawnedChild); } catch (error) { -@@ -5028,6 +5067,12 @@ var AcpClient = class { +@@ -4422,7 +4461,10 @@ var AcpClient = class { + const createPromise = this.runConnectionRequest(() => connection.newSession({ + cwd: sessionCwd, + mcpServers: this.options.mcpServers ?? [], +- _meta: buildClaudeCodeOptionsMeta(this.options.sessionOptions, claudeAcp) ++ _meta: this.isGrokBuildAcpCommand() ++ ? { rules: typeof this.options.sessionOptions?.systemPrompt === "string" ++ ? this.options.sessionOptions.systemPrompt : this.options.sessionOptions?.systemPrompt?.append ?? "" } ++ : buildClaudeCodeOptionsMeta(this.options.sessionOptions, claudeAcp) + })); + result = claudeAcp ? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs()) : await createPromise; + } catch (error) { +@@ -4864,7 +4906,7 @@ var AcpClient = class { + } + selectAuthMethod(methods) { + for (const method of methods) { +- const envCredential = readEnvCredential(method.id); ++ const envCredential = readEnvCredential(method.id, this.options.spawnEnvironment?.() ?? process.env); + if (envCredential) return { + methodId: method.id, + credential: envCredential, +@@ -4890,7 +4932,7 @@ var AcpClient = class { + } + readAgentSpecificEnvCredential(methodId) { + if (!this.isGrokBuildAcpCommand() || methodId !== "xai.api_key") return; +- const value = process.env.XAI_API_KEY; ++ const value = (this.options.spawnEnvironment?.() ?? process.env).XAI_API_KEY; + return typeof value === "string" && value.trim().length > 0 ? value : void 0; + } + selectAgentManagedAuthMethod(methodId) { +@@ -5028,6 +5070,12 @@ var AcpClient = class { attachAgentLifecycleObservers(child) { child.once("exit", (exitCode, signal) => { this.recordAgentExit("process_exit", exitCode, signal); @@ -158,7 +201,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e930a933d259e79a6b03b1148aa58d68 }); child.once("close", (exitCode, signal) => { this.recordAgentExit("process_close", exitCode, signal); -@@ -6435,6 +6480,27 @@ async function withConnectedSession(options) { +@@ -6435,6 +6483,27 @@ async function withConnectedSession(options) { //#region src/runtime/engine/prompt-turn.ts const SESSION_REPLY_IDLE_MS = 1e3; const SESSION_REPLY_DRAIN_TIMEOUT_MS = 5e3; @@ -186,7 +229,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e930a933d259e79a6b03b1148aa58d68 async function runPromptTurn(params) { try { const promptPromise = params.client.prompt(params.sessionId, params.prompt, params.onPromptRequestStarted, params.onElicitation); -@@ -6444,6 +6510,19 @@ async function runPromptTurn(params) { +@@ -6444,6 +6513,19 @@ async function runPromptTurn(params) { idleMs: SESSION_REPLY_IDLE_MS, timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS }).catch(() => {}); @@ -206,15 +249,8 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e930a933d259e79a6b03b1148aa58d68 recordPromptResponseUsage(params.conversation, response.usage, params.promptMessageId); return { stopReason: response.stopReason, -@@ -6518,4 +6597,4 @@ var LiveSessionCheckpoint = class { - //#endregion - export { writeSessionRecord as $, PERMISSION_POLICY_ACTIONS as $t, REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE as A, TimeoutError as At, getAcpxVersion as B, formatErrorMessage as Bt, mergeSessionOptions as C, PromptInputValidationError as Ct, applyLifecycleSnapshotToRecord as D, promptToDisplayText as Dt, applyConversation as E, parsePromptSource as Et, modelStateFromConfigOptions as F, normalizeAgentName$1 as Ft, findSession as G, toAcpErrorPayload as Gt, DEFAULT_HISTORY_LIMIT as H, normalizeOutputError as Ht, normalizeAgentCommandInput as I, resolveAgentArgv as It, listSessions as J, NON_INTERACTIVE_PERMISSION_POLICIES as Jt, findSessionByDirectoryWalk as K, AUTH_POLICIES as Kt, renderArgvIdentity as L, resolveAgentCommand as Lt, RequestedModelUnsupportedError as M, withTimeout as Mt, assertRequestedModelSupported as N, DEFAULT_AGENT_NAME as Nt, reconcileAgentSessionId as O, textPrompt as Ot, isRequestedModelUnsupportedError as P, listBuiltInAgents as Pt, resolveSessionRecord as Q, PERMISSION_MODES as Qt, runTimedExecFile as R, resolveCanonicalAgentName as Rt, advertisedModelState as S, parsePromptStopReason as St, sessionOptionsFromRecord as T, mergePromptSourceWithText as Tt, absolutePath as U, extractAcpError as Ut, permissionModeSatisfies as V, isRetryablePromptError as Vt, findGitRepositoryRoot as W, isAcpResourceNotFoundError as Wt, normalizeName as X, OUTPUT_ERROR_ORIGINS as Xt, listSessionsForAgent as Y, OUTPUT_ERROR_CODES as Yt, pruneSessions as Z, OUTPUT_FORMATS as Zt, createSessionConversation as _, sessionEventLockPath as _t, applyRequestedModelIfAdvertised as a, measurePerf as at, recordSessionUpdate as b, isAcpJsonRpcMessage as bt, setCurrentModelId as c, setPerfGauge as ct, setDesiredModelId as d, serializeSessionRecordForDisk as dt, SESSION_RECORD_SCHEMA as en, createAtomicWriteTempPath as et, syncAdvertisedModelState as f, normalizeRuntimeSessionId as ft, cloneSessionConversation as g, sessionEventActivePath as gt, cloneSessionAcpxState as h, sessionBaseDir$1 as ht, connectAndLoadSession as i, QueueProtocolError as in, incrementPerfCounter as it, REQUESTED_MODEL_UNSUPPORTED_REASONS as j, withInterrupt as jt, AcpClient as k, InterruptedError as kt, setDesiredConfigOption as l, startPerfTimer as lt, applyConfigOptionsToState as m, defaultSessionEventLog as mt, runPromptTurn as n, AgentSpawnError as nn, formatPerfMetric as nt, currentModelIdFromSetModelResponse as o, recordPerfDuration as ot, applyConfigOptionsToRecord as p, DEFAULT_EVENT_SEGMENT_MAX_BYTES as pt, isoNow$2 as q, EXIT_CODES as qt, withConnectedSession as r, QueueConnectionError as rn, getPerfMetricsSnapshot as rt, clearDesiredConfigOption as s, resetPerfMetrics as st, LiveSessionCheckpoint as t, AcpxOperationalError as tn, assertPersistedKeyPolicy as tt, setDesiredModeId as u, parseSessionRecord as ut, recordClientOperation as v, sessionEventSegmentPath as vt, persistSessionOptions as w, isPromptInput as wt, trimConversationForRuntime as x, parseJsonRpcErrorMessage as xt, recordPromptSubmission as y, extractSessionUpdateNotification as yt, splitCommandLine as z, exitCodeForOutputErrorCode as zt }; - --//# sourceMappingURL=live-checkpoint-BSIrfgVo.js.map -\ No newline at end of file -+//# sourceMappingURL=live-checkpoint-BSIrfgVo.js.map diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts -index e8102acb03c4c38830ad5ec22f356125eb0423b7..2277321c58c1265ca930c69eb3f8869bc30cfa8a 100644 +index e8102ac..2277321 100644 --- a/dist/runtime.d.ts +++ b/dist/runtime.d.ts @@ -1,7 +1,8 @@ @@ -311,7 +347,7 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..2277321c58c1265ca930c69eb3f8869b text: string; attachments?: AcpRuntimeTurnAttachment[]; diff --git a/dist/runtime.js b/dist/runtime.js -index a1f4a70a003792c6eacf68b6b038f37bfec1db53..57117c9f6f2e9c737b138bc86977a43b3383f379 100644 +index a1f4a70..57117c9 100644 --- a/dist/runtime.js +++ b/dist/runtime.js @@ -371,7 +371,7 @@ const PROMPT_EVENT_PARSERS = { @@ -511,15 +547,8 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..57117c9f6f2e9c737b138bc86977a43b async cancel(input) { const { handle } = this.resolveManagerHandle(input.handle); await (await this.getManager()).cancel(handle); -@@ -2178,4 +2260,4 @@ function createRuntimeStore(options) { - //#endregion - export { ACPX_BACKEND_ID, AcpRuntimeError, AcpxRuntime, DEFAULT_AGENT_NAME, REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE, REQUESTED_MODEL_UNSUPPORTED_REASONS, RequestedModelUnsupportedError, createAcpRuntime, createAgentRegistry, createFileSessionStore, createRuntimeStore, decodeAcpxRuntimeHandleState, encodeAcpxRuntimeHandleState, isAcpRuntimeError, isRequestedModelUnsupportedError }; - --//# sourceMappingURL=runtime.js.map -\ No newline at end of file -+//# sourceMappingURL=runtime.js.map diff --git a/dist/session-options-DwRDODlr.d.ts b/dist/session-options-DwRDODlr.d.ts -index c3da1645235bbea22de3f8484149051cd7dca56b..ab6c1bf8aaf879800552d22f357e88b57b36faa2 100644 +index c3da164..ab6c1bf 100644 --- a/dist/session-options-DwRDODlr.d.ts +++ b/dist/session-options-DwRDODlr.d.ts @@ -1,4 +1,5 @@ diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 96f97b58857..dd3134486e7 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -405,7 +405,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { } if (profile.provider === "acpx") { try { - if (profile.acpxAgent !== "claude") throw new Error("Select Codex to use the native Codex runner."); + if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok") throw new Error("Select Codex to use the native Codex runner."); const target = context.executionTarget; if (target?.kind === "remote") { const probe = await runAdapterExecutionTargetShellCommand( @@ -414,8 +414,8 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ); if (probe.timedOut || probe.exitCode !== 0) throw new Error("Could not verify the remote ACPX runner platform."); const [os, arch] = probe.stdout.trim().split(/\s+/); - if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && ["arm64", "x86_64"].includes(arch ?? "")))) { - throw new Error("ACPX Claude requires Linux x64 or macOS ARM64/x64."); + if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) { + throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`); } return { adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(), @@ -423,11 +423,11 @@ const paperclipRunnerAdapter: ServerAdapterModule = { message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }], }; } - const { probeAcpxClaudeInstallation } = await import("@paperclipai/paperclip-runner/live"); - await probeAcpxClaudeInstallation(profile.model); + const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation } = await import("@paperclipai/paperclip-runner/live"); + await (profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : probeAcpxClaudeInstallation)(profile.model); return { adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(), - checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: "ACPX Claude runtime is installed and verified. Model access is checked when Claude runs." }], + checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: `ACPX ${profile.acpxAgent} runtime is installed and verified. Model access is checked when it runs.` }], }; } catch (error) { return { @@ -510,7 +510,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ) : buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.153.4"), agentConfigurationDoc: - "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude through the Rust Paperclip runner and authenticated PRP transport. Pi is not available through the qualified ACPX profile. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", + "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build through the Rust Paperclip runner and authenticated PRP transport. Pi is not available through the qualified ACPX profile. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", getConfigSchema: () => ({ fields: [ { @@ -527,6 +527,11 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ], hint: "Select a local provider, company-qualified managed provider, or ACPX Claude.", }, + { + key: "acpxAgent", label: "ACP agent", type: "select" as const, default: "claude", + options: [{ value: "claude", label: "Claude" }, { value: "grok", label: "Grok Build" }], + meta: { visibleWhen: { key: "provider", value: "acpx" } }, + }, { key: "codexPermissionMode", label: "Codex permission mode", diff --git a/server/src/services/native-runtime/grok-runner-credentials.test.ts b/server/src/services/native-runtime/grok-runner-credentials.test.ts new file mode 100644 index 00000000000..f9b39d3b518 --- /dev/null +++ b/server/src/services/native-runtime/grok-runner-credentials.test.ts @@ -0,0 +1,33 @@ +import { mkdtemp, mkdir, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js"; + +const { companyHome } = vi.hoisted(() => ({ companyHome: vi.fn() })); +vi.mock("@paperclipai/adapter-grok-local/server", () => ({ + resolveManagedGrokHomeDir: companyHome, + grokHomeHasUsableAuth: async () => false, +})); +const roots: string[] = []; +async function home() { const root = await realpath(await mkdtemp(join(tmpdir(), "grok-controller-test-"))); roots.push(root); return root; } +afterEach(async () => { await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))); vi.clearAllMocks(); }); + +describe("Grok runner company credentials", () => { + it("requires the company login for remote runs and never borrows a configured host home", async () => { + const root = await home(); + companyHome.mockReturnValue(join(root, "company-without-login")); + const host = join(root, "host"); await mkdir(host, { mode: 0o700 }); + await writeFile(join(host, "auth.json"), "host-secret", { mode: 0o600 }); + await expect(prepareGrokRunnerCredentials({ companyId: "test-company", remote: true, environment: { GROK_HOME: host } })).rejects.toThrow("subscription login is unavailable"); + }); + it("uses only an explicitly selected API key and strips subscription payloads and homes", async () => { + expect(await prepareGrokRunnerCredentials({ companyId: "test-company", remote: true, environment: { XAI_API_KEY: "selected", GROK_HOME: "/host", PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: "stale" } })).toEqual({ environment: { XAI_API_KEY: "selected" }, home: null }); + }); + it("reads the controller-selected managed connection privately", async () => { + const root = await home(); companyHome.mockReturnValue(join(root, "unused")); + await writeFile(join(root, "auth.json"), '{"private":"fixture"}', { mode: 0o600 }); + const value = await prepareGrokRunnerCredentials({ companyId: "test-company", remote: true, managedHome: root, environment: { GROK_HOME: "/untrusted", XAI_API_KEY: "" } }); + expect(value).toEqual({ home: root, environment: { PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: '{"private":"fixture"}' } }); + }); +}); diff --git a/server/src/services/native-runtime/grok-runner-credentials.ts b/server/src/services/native-runtime/grok-runner-credentials.ts new file mode 100644 index 00000000000..2ba08481022 --- /dev/null +++ b/server/src/services/native-runtime/grok-runner-credentials.ts @@ -0,0 +1,30 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { grokHomeHasUsableAuth, resolveManagedGrokHomeDir } from "@paperclipai/adapter-grok-local/server"; +import { readLocalAiCredentialFile } from "../local-ai-credential-file.js"; + +/** Resolve credentials on the controller. Remote runtimes receive only the + * selected credential, never an ambient host home or an implicit API key. + */ +export async function prepareGrokRunnerCredentials(input: { + companyId: string; + environment: NodeJS.ProcessEnv; + remote: boolean; + managedHome?: string; +}): Promise<{ environment: NodeJS.ProcessEnv; home: string | null }> { + const environment = { ...input.environment }; + delete environment.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET; + const configuredHome = input.managedHome?.trim(); + delete environment.GROK_HOME; + if (environment.XAI_API_KEY?.trim()) return { environment, home: null }; + delete environment.XAI_API_KEY; + const companyHome = resolveManagedGrokHomeDir(input.environment, input.companyId); + const home = configuredHome ?? (await grokHomeHasUsableAuth(companyHome) + ? companyHome : input.remote ? companyHome : join(homedir(), ".grok")); + try { + environment.PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET = await readLocalAiCredentialFile(join(home, "auth.json")); + } catch { + throw new Error("Grok subscription login is unavailable. Connect Grok Build or select an xAI API key."); + } + return { environment, home }; +} diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index a6e79f988f1..19fcaa9bcc0 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -970,6 +970,9 @@ describe("remote provider pack manifest", () => { const lockfile = "lockfileVersion: '9.0'\n"; const opencodeCommand = "#!/bin/sh\n"; const opencodeExecutable = "opencode-binary\n"; + const grokExecutable = "grok-binary\n"; + await mkdir(join(root, "node_modules/@paperclipai/grok-acp/bin"), { recursive: true }); + await writeFile(join(root, "node_modules/@paperclipai/grok-acp/bin/grok"), grokExecutable); await writeFile( join(root, "dist", "cli", "opencode-app-server-proxy.cjs"), proxy, @@ -1000,18 +1003,21 @@ describe("remote provider pack manifest", () => { acpx: "0.13.1", claudeAcp: "0.73.0", codexAcp: "1.6.2", + grok: "1.0.13", }, target: { platform: "linux", architecture: "x64" }, runnerSourceRevision: "1".repeat(40), distDigest: sha256DirectoryTree(join(root, "dist")), bridgeDigest: "", acpxProfileDigests: { + grok: "sha256:42fe296ec6fc0715c3509cec9671451bcd5bfdc7f185041101c8aac1e9ac8718", claude: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", codex: "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", }, artifacts: { + grokExecutable: { path: "node_modules/@paperclipai/grok-acp/bin/grok", sha256: digest(grokExecutable) }, nodeCommand: { path: "node_modules/node/bin/node", sha256: digest(node), diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 5f350aaa7bc..982c115f3bf 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1,3 +1,6 @@ +import { readLocalAiCredentialFile } from "../local-ai-credential-file.js"; +import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js"; +import { copyBackGrokAuth } from "@paperclipai/adapter-grok-local/server"; import { createNativeGitHubAccess, type NativeGitHubAccess } from "./native-github-access.js"; import { resolveGitHubOperationCredentials } from "../github-operation-credentials.js"; import { bindManagedNativeCredentialTurn, completeManagedNativeCredentialTurn } from "./managed-native-credentials.js"; @@ -5250,7 +5253,10 @@ export function resolveNativeHarnessPersistenceProfile( // nor necessary to restore. Credentials and launch-time config // are also re-materialized in the replacement sandbox. excludeEntries: - execution.provider.agent === "codex" + execution.provider.agent === "grok" + ? ["auth.json", "auth-refresh.json", "auth-refresh.json.tmp", "config.toml"].map((entry) => + `acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/grok-home/${entry}`) + : execution.provider.agent === "codex" ? CODEX_HOME_NON_PERSISTENT_ENTRIES.map( (entry) => `acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(execution))}/codex-home/${entry}`, @@ -9132,14 +9138,17 @@ const REMOTE_PROVIDER_PACK_PINS = { acpx: "0.13.1", claudeAcp: "0.73.0", codexAcp: "1.6.2", + grok: "1.0.13", } as const; const REMOTE_PROVIDER_PACK_PROFILE_DIGESTS = { + grok: "sha256:42fe296ec6fc0715c3509cec9671451bcd5bfdc7f185041101c8aac1e9ac8718", claude: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", codex: "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", } as const; const REMOTE_PROVIDER_PACK_ARTIFACT_PATHS = { + grokExecutable: "node_modules/@paperclipai/grok-acp/bin/grok", nodeCommand: "node_modules/node/bin/node", productionLock: "pnpm-lock.yaml", opencodeCommand: "node_modules/.bin/opencode", @@ -9159,6 +9168,7 @@ type RemoteProviderPackManifest = { bridgeDigest: string; acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS; artifacts: { + grokExecutable: { path: string; sha256: string }; nodeCommand: { path: string; sha256: string }; productionLock: { path: string; sha256: string }; opencodeCommand: { path: string; sha256: string }; @@ -9257,6 +9267,7 @@ export function readRemoteProviderPackManifest( ); } const artifactEntries = [ + ["Grok executable", payload.artifacts?.grokExecutable, REMOTE_PROVIDER_PACK_ARTIFACT_PATHS.grokExecutable], [ "provider Node", payload.artifacts?.nodeCommand, @@ -10680,14 +10691,14 @@ async function createRunnerdBackendWithinSessionClaim( "if(canonical(manifest)!==expected)throw new Error('manifest mismatch')", "const hash=(p)=>'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(path.join(root,p))).digest('hex')", "const tree=(treeRoot)=>{const digest=crypto.createHash('sha256');const visit=(directory,prefix='')=>{for(const entry of fs.readdirSync(directory,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){const relative=prefix?prefix+'/'+entry.name:entry.name;const absolute=path.join(directory,entry.name);if(entry.isDirectory()){digest.update('directory\\0'+relative+'\\n');visit(absolute,relative)}else if(entry.isFile()){digest.update('file\\0'+relative+'\\0'+'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')+'\\n')}else if(entry.isSymbolicLink()){digest.update('symlink\\0'+relative+'\\0'+fs.readlinkSync(absolute)+'\\n')}else throw new Error('unsupported dist entry '+relative)}};visit(treeRoot);return 'sha256:'+digest.digest('hex')}", - "for(const name of ['nodeCommand','productionLock','opencodeCommand','opencodeExecutable','opencodeProxy','acpxSidecar']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}", + "for(const name of ['nodeCommand','productionLock','opencodeCommand','opencodeExecutable','opencodeProxy','acpxSidecar','grokExecutable']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}", "if(tree(path.join(root,'dist'))!==manifest.payload.distDigest)throw new Error('dist tree digest mismatch')", "const version=process.versions.node.split('.').map(Number)", "const minimum=manifest.payload.pins.nodeMinimum.split('.').map(Number)", "if(version[0]JSON.parse(fs.readFileSync(path.join(root,'node_modules',...pkg.split('/'),'package.json'),'utf8')).version", - "const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode}", + "const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode,'@paperclipai/grok-acp':manifest.payload.pins.grok}", "for(const [pkg,version] of Object.entries(expectedPackages))if(packageVersion(pkg)!==version)throw new Error(pkg+' version mismatch')", ].join(";"); const verified = await remoteCommandRunner.execute({ @@ -11975,9 +11986,15 @@ async function createRunnerdBackendWithinSessionClaim( }, } : input.execution; - const effectiveRunnerEnvironmentBase: NodeJS.ProcessEnv = { - ...(input.runnerEnvironment ?? process.env), + const isGrok = input.execution.provider.kind === "acpx" && input.execution.provider.agent === "grok"; + let effectiveRunnerEnvironmentBase: NodeJS.ProcessEnv = { + ...(input.runnerEnvironment ?? (isGrok ? {} : process.env)), }; + const grokCredential = isGrok ? await prepareGrokRunnerCredentials({ + companyId: input.execution.binding.companyId, environment: effectiveRunnerEnvironmentBase, remote: Boolean(remoteTarget), + managedHome: input.managedAiCredentialHome, + }) : null; + if (grokCredential) effectiveRunnerEnvironmentBase = grokCredential.environment; // This authority bit is derived only from the selected execution target. // Never let an agent, environment binding, or host variable disable the // Codex sandbox for a local runner by supplying the same key. @@ -12547,6 +12564,38 @@ async function createRunnerdBackendWithinSessionClaim( }); const boundManagedSessions = new WeakSet(); const wrapManagedSession = (session: NativeSession): NativeSession => { + if (isGrok && grokCredential?.home && !boundManagedSessions.has(session)) { + boundManagedSessions.add(session); + const relativeHome = `acpx/${acpxRuntimeSessionDirectoryName(nativeSessionKey(input.execution))}/grok-home`; + const localHome = join(root, "acpx", relativeHome); + const remoteHome = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "acpx", relativeHome) : null; + const readAuth = async (name: string): Promise => { + if (!remoteHome || !remoteCommandRunner) return Buffer.from(await readLocalAiCredentialFile(join(localHome, name))); + const script = `const fs=require('node:fs'),path=require('node:path');let fd;try{const file=process.argv[1];let parent=path.dirname(file);while(true){if(!fs.lstatSync(parent).isDirectory())throw Error('directory');const next=path.dirname(parent);if(next===parent)break;parent=next;}fd=fs.openSync(file,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);const st=fs.fstatSync(fd);if(!st.isFile()||st.uid!==process.getuid()||(st.mode&511)!==384||st.size>65536)throw Error('credential');const b=Buffer.alloc(65537);let n=0;while(n65536)throw Error('size');process.stdout.write(b.subarray(0,n).toString('base64'));b.fill(0);}catch(e){process.exitCode=e.code==='ENOENT'?66:1;}finally{if(fd!==undefined)fs.closeSync(fd);}`; + const result = await remoteCommandRunner.execute({ command: "node", args: ["-e", script, posix.join(remoteHome, name)], bypassSession: true, timeoutMs: 10000 }); + if (result.exitCode !== 0 || result.timedOut) throw Object.assign(new Error("Grok credential refresh handoff unavailable"), { code: result.exitCode === 66 ? "ENOENT" : "INVALID_CREDENTIAL" }); + return Buffer.from(result.stdout, "base64"); + }; + return bindManagedNativeCredentialTurn(session, { + copyBack: async () => { + await copyBackGrokAuth({ hostHomeDir: grokCredential.home!, log: () => {}, + readSandboxAuth: () => readAuth("auth.json").catch((error) => { + if (error.code !== "ENOENT") throw error; + return readAuth("auth-refresh.json"); + }), + }); + }, + remove: async () => { + for (const name of ["auth.json", "auth-refresh.json", "auth-refresh.json.tmp"]) { + rmSync(join(localHome, name), { force: true }); + if (remoteHome && remoteCommandRunner) { + const result = await remoteCommandRunner.execute({ command: "rm", args: ["-f", "--", posix.join(remoteHome, name)], bypassSession: true, timeoutMs: 10000 }); + if (result.exitCode !== 0 || result.timedOut) throw new Error("Grok credential cleanup failed"); + } + } + }, + }); + } if (!input.managedAiCredentialHome || input.execution.provider.kind !== "codex" || boundManagedSessions.has(session)) return session; boundManagedSessions.add(session); const remoteAuth = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "codex-home", "auth.json") : null; diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 7cd125783d7..08afac0924e 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -15,6 +15,7 @@ export const DEFAULT_OPENCODE_RUNNER_MODEL = export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const; export const QUALIFIED_ACPX_RUNNER_MODELS = { + grok: "grok-4.7", claude: "claude-sonnet-5", codex: "gpt-5.6-sol", } as const; @@ -404,7 +405,7 @@ export function resolvePaperclipRunnerProviderProfile( } const acpxAgent = config.acpxAgent ?? "claude"; - if (acpxAgent !== "claude" && acpxAgent !== "codex") { + if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok") { throw new PaperclipRunnerProviderProfileError( "paperclip_runner_acpx_agent_unavailable", "Paperclip Runner ACPX requires the qualified Claude or Codex agent profile; Pi is not available.", diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index d10684439ec..4d61852f42d 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1001,3 +1001,13 @@ requires the saved content and usable composer to remain visible. Run it with th standard `tests/e2e/playwright.config.ts`; no provider or Daytona credentials are needed. Browser-support tests separately exercise blank-root/pending-module failure evidence, so a future blank page is distinguishable from a loaded task. + +### Grok Build qualification + +`runner-acpx-grok` uses native ACPX with Grok Build 1.0.13 and `grok-4.7`. +Set an explicit `XAI_API_KEY` for this API-key profile. The manual +`grok-qualification` suite runs the core and restart workflows on local and +Daytona runtimes. Missing subscription coverage remains a qualification blocker; +an API-key pass must not be reported as subscription evidence. See +[`doc/grok-native-runner.md`](../../doc/grok-native-runner.md) for installation, +credential boundaries and repetition requirements. diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index 78d38e401b0..1f1c53dd2a5 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -64,25 +64,25 @@ describe("runner E2E catalog", () => { }); it("validates the core, local-integrity, breadth, and warm suites", () => { - expect(runnerProfiles).toHaveLength(7); + expect(runnerProfiles).toHaveLength(8); expect(openRouterBreadthProfiles).toHaveLength(4); expect(runnerEnvironments).toHaveLength(2); expect(runnerTasks).toHaveLength(3); expect(localIntegrityTasks).toHaveLength(2); expect(openRouterBreadthTasks).toHaveLength(3); expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([ - 23, 38, 52, 28, 18, 6, 6, 42, 14, 10, 2, + 10, 23, 38, 52, 28, 18, 6, 6, 48, 16, 10, 2, ]); - expect(validateRunnerCatalog()).toHaveLength(239); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(239); + expect(validateRunnerCatalog()).toHaveLength(257); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(257); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), - ).toHaveLength(42); + ).toHaveLength(48); expect( runnerMatrix.filter( (entry) => entry.suite.id === "local-session-integrity", ), - ).toHaveLength(14); + ).toHaveLength(16); expect( runnerMatrix.filter( (entry) => entry.suite.id === "openrouter-model-breadth", @@ -98,7 +98,7 @@ describe("runner E2E catalog", () => { (total, execution) => total + execution.task.expectedRunCount, 0, ), - ).toBe(371); + ).toBe(385); expect( runnerTasks.find((task) => task.id === "plan-revise-accept") ?.attemptTimeoutMs, @@ -572,7 +572,7 @@ describe("runner E2E selectors", () => { "daytona", ]); const selected = selectRunnerExecutions(options); - expect(selected).toHaveLength(13); + expect(selected).toHaveLength(16); expect( selected.every( (entry) => @@ -591,10 +591,10 @@ describe("runner E2E selectors", () => { const jobs = buildMatrixJobs( selectRunnerExecutions(parseRunnerSelectors(["--all"])), ); - expect(jobs).toHaveLength(171); - expect(jobs.filter((job) => job.needsDaytona)).toHaveLength(23); - expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(148); - expect(new Set(jobs.map((job) => job.executionId)).size).toBe(171); + expect(jobs).toHaveLength(179); + expect(jobs.filter((job) => job.needsDaytona)).toHaveLength(26); + expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(153); + expect(new Set(jobs.map((job) => job.executionId)).size).toBe(179); expect( jobs.find( (job) => diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 28fa16c2d36..8f54114ba67 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -142,7 +142,7 @@ function nativeProfile(input: { provider: "codex" | "opencode" | "acpx"; model: string; credential: RunnerProfileFixture["credential"]; - acpxAgent?: "claude" | "codex"; + acpxAgent?: "claude" | "codex" | "grok"; supportedEnvironments?: readonly (typeof ENVIRONMENT_IDS)[number][]; modelQualification?: RunnerProfileFixture["modelQualification"]; ranking?: RunnerProfileFixture["ranking"]; @@ -281,6 +281,14 @@ export const runnerProfiles: readonly RunnerProfileFixture[] = [ model: QUALIFIED_ACPX_PROFILES.claude.qualificationModel, credential: "ANTHROPIC_API_KEY", }), + nativeProfile({ + id: "runner-acpx-grok", + label: "Runner Grok Build", + provider: "acpx", + acpxAgent: "grok", + model: QUALIFIED_ACPX_PROFILES.grok.qualificationModel, + credential: "XAI_API_KEY", + }), nativeProfile({ id: "runner-acpx-codex", label: "Runner ACPX Codex", @@ -909,6 +917,15 @@ const everydayProfiles = [ ].map(productionStoryProfile); export const runnerSuites: readonly RunnerSuiteFixture[] = [ + { + id: "grok-qualification", label: "Grok Build Qualification", manualOnly: true, + description: "Grok task replies, planning approval, questions and controller restart in local and Daytona environments.", + groups: ["native"], + profiles: runnerProfiles.filter(profile => profile.id === "runner-acpx-grok"), + environments: [localEnvironment, daytonaWarmEnvironment], + tasks: [...runnerTasks, ...localIntegrityTasks], expectedMatrixSize: 10, + definitionMetadata: { version: 1, binary: "1.0.13", model: "grok-4.7", scheduling: "explicit-only", repetitionsRequired: 3 }, + }, { id: "continuation", label: "Task continuation", description: "Human direction, approval boundaries, untrusted evidence, and completed actions across turns.", @@ -987,7 +1004,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ profiles: runnerProfiles, environments: runnerEnvironments, tasks: runnerTasks, - expectedMatrixSize: 42, + expectedMatrixSize: 48, }, { id: "local-session-integrity", @@ -998,7 +1015,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ profiles: runnerProfiles, environments: [localEnvironment], tasks: localIntegrityTasks, - expectedMatrixSize: 14, + expectedMatrixSize: 16, }, { id: "openrouter-model-breadth", @@ -1182,6 +1199,7 @@ export function validateRunnerCatalog(): MatrixExecution[] { "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", + "XAI_API_KEY", "DAYTONA_API_KEY", ].map((name, index) => [ name, diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts index 23a9a5dca8f..7f8b3b03866 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -27,6 +27,10 @@ export const DAYTONA_IMAGE_INPUT_PATHS = [ "packages/paperclip-eval-kernel/package.json", "packages/paperclip-eval-kernel/src", "packages/paperclip-eval-kernel/tsconfig.json", + "packages/grok-acp/package.json", + "packages/grok-acp/launcher.cjs", + "packages/grok-acp/install.mjs", + "packages/grok-acp/platforms.json", "packages/paperclip-runner/package.json", "packages/paperclip-runner/protocol", "packages/paperclip-runner/runner/Cargo.lock", diff --git a/tests/runner-e2e/types.ts b/tests/runner-e2e/types.ts index 81a4f22ef0f..8bb7f0df3a1 100644 --- a/tests/runner-e2e/types.ts +++ b/tests/runner-e2e/types.ts @@ -2,6 +2,7 @@ export const CREDENTIAL_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", + "XAI_API_KEY", "DAYTONA_API_KEY", ] as const; diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index 1a9c021dd0d..c627ca44c44 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -511,6 +511,7 @@ describe("public repository paid workflow security", () => { OPENAI_API_KEY: "matrix.credentialName == 'OPENAI_API_KEY'", ANTHROPIC_API_KEY: "matrix.credentialName == 'ANTHROPIC_API_KEY'", OPENROUTER_API_KEY: "matrix.credentialName == 'OPENROUTER_API_KEY'", + XAI_API_KEY: "matrix.credentialName == 'XAI_API_KEY'", DAYTONA_API_KEY: "matrix.environmentId == 'daytona'", })) { expect(fullStack).toContain( diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 5d569a13851..c89b8af0e20 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -195,9 +195,10 @@ export function CodexLocalConfigFields({ > )} diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index fb4e0b4d122..b0f18ffc266 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1066,6 +1066,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { if (props.compactTestFeedback) { const providerAdapter = adapterType === "paperclip_runner" ? adapterConfig.provider === "codex" ? "codex_local" + : adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "grok" ? "grok_local" : adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local" : adapterType : adapterType; @@ -1650,7 +1651,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} - {!isCreate && selectedCompanyId && | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data} model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy onChange={binding => mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding })} />} diff --git a/ui/src/components/new-agent/AgentBasicsDialog.tsx b/ui/src/components/new-agent/AgentBasicsDialog.tsx index 173e1cd248d..d778c2bb146 100644 --- a/ui/src/components/new-agent/AgentBasicsDialog.tsx +++ b/ui/src/components/new-agent/AgentBasicsDialog.tsx @@ -274,6 +274,7 @@ export function AgentBasicsDialog({ > + diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index 5ebb4e318be..7f55a0b2360 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -111,7 +111,9 @@ function Setup({ const appearanceDraft = useAgentAppearanceDraft(`${companyId}:new-agent`); const isRunner = adapterType === "paperclip_runner"; const brandType = isRunner - ? runnerProvider === "claude" + ? runnerProvider === "grok" + ? "grok_local" + : runnerProvider === "claude" ? "claude_local" : runnerProvider === "opencode" ? "opencode_local" @@ -340,8 +342,8 @@ function Setup({ ...(isRunner ? { adapterSchemaValues: { - provider: runnerProvider === "claude" ? "acpx" : runnerProvider, - ...(runnerProvider === "claude" ? { acpxAgent: "claude" } : {}), + provider: (runnerProvider === "claude" || runnerProvider === "grok") ? "acpx" : runnerProvider, + ...((runnerProvider === "claude" || runnerProvider === "grok") ? { acpxAgent: runnerProvider } : {}), }, } : {}), @@ -349,8 +351,8 @@ function Setup({ const config = getUIAdapter(adapterType).buildAdapterConfig(values); if (isRunner) Object.assign(config, { - provider: runnerProvider === "claude" ? "acpx" : runnerProvider, - ...(runnerProvider === "claude" ? { acpxAgent: "claude" } : {}), + provider: (runnerProvider === "claude" || runnerProvider === "grok") ? "acpx" : runnerProvider, + ...((runnerProvider === "claude" || runnerProvider === "grok") ? { acpxAgent: runnerProvider } : {}), ...(model ? { model } : {}), }); if (!aiBinding && !nextConnection?.aiConnection && hasCredentialField && binding) {