feat(runner): add Grok Build native ACP transport and qualification coverage

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-22 16:14:59 -05:00
1 parent f447990d77
commit 78eb3f19f3
65 files changed
+1017 -111

No files matched your search

+2
View File
@@ -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
+1
View File
@@ -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=
+128 -1
View File
@@ -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
@@ -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
+70
View File
@@ -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.
+6 -8
View File
@@ -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
@@ -233,8 +233,8 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
: {}),
...(provider === "acpx"
? {
acpxAgent: "claude",
model: configuredModel || schemaModel || resolvePaperclipRunnerModel("acpx", undefined),
acpxAgent: schemaValues.acpxAgent === "grok" ? "grok" : "claude",
model: configuredModel || schemaModel || (schemaValues.acpxAgent === "grok" ? "grok-4.7" : resolvePaperclipRunnerModel("acpx", undefined)),
}
: {}),
...(provider === "claude_managed"
@@ -84,3 +84,5 @@ export {
export { decideGrokAuthMerge } from "./grok-auth-merge-decision.js";
export { parseGrokAuthPayload, hasUsableGrokAuthValue } from "./grok-home.js";
export { copyBackGrokAuth } from "./grok-auth-copyback.js";
+1
View File
@@ -0,0 +1 @@
/bin/
+25
View File
@@ -0,0 +1,25 @@
import { createHash } from "node:crypto";
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { gunzipSync } from "node:zlib";
const manifest = JSON.parse(await readFile(new URL("./platforms.json", import.meta.url), "utf8"));
const platform = manifest.platforms[`${process.platform}-${process.arch}`];
if (!platform) throw new Error(`Unqualified Grok platform: ${process.platform}-${process.arch}`);
const destination = fileURLToPath(new URL("./bin/grok", import.meta.url));
const digest = (bytes) => 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})`);
+9
View File
@@ -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);
+21
View File
@@ -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"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "1.0.13",
"platforms": {
"darwin-arm64": {
"artifact": "macos-aarch64",
"sha256": "8669e0fdadceec25b8c159c355f427ffbd82583525d774b6ab1522197ea83b80"
},
"linux-x64": {
"artifact": "linux-x86_64",
"sha256": "edf79521581bb5e6b95abef848491a6a742e860da3e237ebe86a280d30dce4c1"
}
}
}
+4 -2
View File
@@ -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",
@@ -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
@@ -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
@@ -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",
))
}
};
@@ -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();
@@ -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)),
@@ -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;
@@ -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";
@@ -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<AcpRuntimeEvent>();
const normalizeMessage = initializedAgent === "grok"
? createGrokMessageNormalizer<AcpRuntimeEvent>() : (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;
}
@@ -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 = {
@@ -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");
@@ -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);
@@ -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") {
@@ -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",
@@ -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<string, unknown> | 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<AcpRuntimeEvent>();
const normalizeMessage = this.#agent === "grok"
? createGrokMessageNormalizer<AcpRuntimeEvent>() : (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,
@@ -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.
@@ -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";
@@ -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"]
@@ -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");
});
});
@@ -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<AcpxProviderLifetimeLease> {
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<void> | 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;
},
};
}
@@ -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(
@@ -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<VerifiedAcpxRuntimeExecutable | null> {
if (input.profile.agent === "grok") {
const digests: Record<string, string> = {
"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<void>
const lease = await installation.openCommand();
await lease.close();
}
export async function probeAcpxGrokInstallation(model: string): Promise<void> {
await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile("grok", model));
}
@@ -55,6 +55,7 @@ export async function createAcpxPrivateSnapshot(
executable: FileHandle | null,
): Promise<AcpxPrivateSnapshot> {
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);
@@ -31,6 +31,14 @@ export interface QualifiedAcpxProfile {
export const QUALIFIED_ACPX_PROFILES: Readonly<
Record<QualifiedAcpxAgent, QualifiedAcpxProfile>
> = 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}`,
);
@@ -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.
@@ -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,
@@ -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;
+1 -1
View File
@@ -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";
@@ -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 {
@@ -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();
});
});
@@ -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 } };
}
@@ -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",
@@ -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);
});
});
@@ -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<T extends AcpRuntimeEventShape>(): (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 {
+2
View File
@@ -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"
+51 -22
View File
@@ -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 @@
+12 -7
View File
@@ -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",
@@ -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"}' } });
});
});
@@ -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 };
}
@@ -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),
@@ -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]<minimum[0]||(version[0]===minimum[0]&&(version[1]<minimum[1]||(version[1]===minimum[1]&&version[2]<minimum[2]))))throw new Error('Node version incompatible')",
"if(process.platform!==manifest.payload.target.platform||process.arch!==manifest.payload.target.architecture)throw new Error('provider pack target mismatch')",
"const packageVersion=(pkg)=>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<NativeSession>();
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<Buffer> => {
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(n<b.length){const k=fs.readSync(fd,b,n,b.length-n,n);if(!k)break;n+=k;}if(n>65536)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;
@@ -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.",
+10
View File
@@ -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.
+12 -12
View File
@@ -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) =>
+21 -3
View File
@@ -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,
@@ -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",
+1
View File
@@ -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;
@@ -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(
@@ -195,9 +195,10 @@ export function CodexLocalConfigFields({
>
<select
className={inputClass}
value={runnerProvider}
value={runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") === "grok" ? "grok" : runnerProvider}
onChange={(event) => {
const provider = isPaperclipRunnerProvider(event.target.value)
const grok = event.target.value === "grok";
const provider = grok ? "acpx" : isPaperclipRunnerProvider(event.target.value)
? event.target.value
: "codex";
const model =
@@ -208,7 +209,7 @@ export function CodexLocalConfigFields({
: provider === "aws_agentcore"
? defaultAwsAgentCoreModel
: provider === "acpx"
? defaultAcpxClaudeModel
? grok ? "grok-4.7" : defaultAcpxClaudeModel
: DEFAULT_CODEX_LOCAL_MODEL;
if (isCreate) {
set!({
@@ -216,14 +217,14 @@ export function CodexLocalConfigFields({
adapterSchemaValues: {
...values!.adapterSchemaValues,
provider,
...(provider === "acpx" ? { acpxAgent: "claude" } : {}),
...(provider === "acpx" ? { acpxAgent: grok ? "grok" : "claude" } : {}),
},
});
} else {
mark("adapterConfig", "provider", provider);
mark("adapterConfig", "model", model);
if (provider === "acpx") {
mark("adapterConfig", "acpxAgent", "claude");
mark("adapterConfig", "acpxAgent", grok ? "grok" : "claude");
}
}
}}
@@ -233,6 +234,7 @@ export function CodexLocalConfigFields({
<option value="claude_managed">Claude Managed</option>
<option value="aws_agentcore">AWS AgentCore</option>
<option value="acpx">ACPX Claude</option>
<option value="grok">Grok Build</option>
</select>
</Field>
)}
+2 -1
View File
@@ -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) {
</Field>
)}
{!isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={adapterType === "paperclip_runner" ? eff("adapterConfig", "provider", config.provider) === "codex" ? "codex_local" : eff("adapterConfig", "provider", config.provider) === "opencode" ? "opencode_local" : eff("adapterConfig", "provider", config.provider) === "acpx" && eff("adapterConfig", "acpxAgent", config.acpxAgent) === "claude" ? "claude_local" : adapterType : adapterType}
{!isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={adapterType === "paperclip_runner" ? eff("adapterConfig", "provider", config.provider) === "codex" ? "codex_local" : eff("adapterConfig", "provider", config.provider) === "opencode" ? "opencode_local" : eff("adapterConfig", "provider", config.provider) === "acpx" && eff("adapterConfig", "acpxAgent", config.acpxAgent) === "grok" ? "grok_local" : eff("adapterConfig", "provider", config.provider) === "acpx" && eff("adapterConfig", "acpxAgent", config.acpxAgent) === "claude" ? "claude_local" : adapterType : adapterType}
value={aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data}
model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy
onChange={binding => mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding })} />}
@@ -274,6 +274,7 @@ export function AgentBasicsDialog({
>
<option value="codex">Codex (app server)</option>
<option value="claude">Claude (ACPX)</option>
<option value="grok">Grok Build (ACPX)</option>
<option value="opencode">OpenCode</option>
</select>
</label>
@@ -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) {