diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index d32dc1169b..7ca7bcac67 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -1158,6 +1158,10 @@ jobs: commit --no-verify -m "ci(canary): stage regenerated lockfile" fi ./scripts/release.sh canary --skip-verify --dry-run + - name: Verify built-in Grok from a clean public npm install + if: ${{ hashFiles('scripts/verify-grok-npm-install.mjs') != '' }} + run: node scripts/verify-grok-npm-install.mjs + e2e_shards: name: e2e shard (${{ matrix.shard_label }}) diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index decdd6865f..dd8c096b71 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -892,7 +892,7 @@ jobs: - name: Install checksum-verified Grok executable if: matrix.environmentId == 'local' && (matrix.profileId == 'runner-acpx-grok' || matrix.profileId == 'runner-acpx-grok-subscription') - run: node packages/grok-acp/install.mjs + run: sudo node packages/paperclip-runner/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok - name: Download immutable campaign outputs if: startsWith(matrix.profileId, 'runner-') || matrix.suiteId == 'openrouter-model-breadth' diff --git a/.github/workflows/runner-protocol-live-evals.yml b/.github/workflows/runner-protocol-live-evals.yml index fb063185a4..7cedfd602c 100644 --- a/.github/workflows/runner-protocol-live-evals.yml +++ b/.github/workflows/runner-protocol-live-evals.yml @@ -356,8 +356,8 @@ jobs: - name: Materialize the pinned Grok executable when the target includes it run: | - if [ -f packages/grok-acp/install.mjs ]; then - node packages/grok-acp/install.mjs + if [ -f packages/paperclip-runner/scripts/provision-grok.mjs ]; then + sudo node packages/paperclip-runner/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok fi - name: Build runner CLI, daemon, and canonical attempt viewer diff --git a/Dockerfile b/Dockerfile index 9d4d81ca90..eddc097b0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,6 @@ COPY packages/shared/package.json packages/shared/ COPY packages/db/package.json packages/db/ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/google-sheets-mcp-server/package.json packages/google-sheets-mcp-server/ -COPY packages/grok-acp/package.json packages/grok-acp/ COPY packages/kv-demo-mcp-server/package.json packages/kv-demo-mcp-server/ COPY packages/mcp-server/package.json packages/mcp-server/ COPY packages/paperclip-eval-kernel/package.json packages/paperclip-eval-kernel/ @@ -294,7 +293,29 @@ RUN set -eu; \ test -n "$specifiers" || { echo "ERROR: CLOUD_BUNDLED_SERVER_DEPS names no package" >&2; exit 1; }; \ pnpm add --ignore-workspace --no-lockfile $specifiers +# ACPX remote runs require a controller-owned provider pack to verify the +# sandbox installation or stage matching assets. Grok's native executable stays +# an external sandbox prerequisite; this pack contains only its launcher. +FROM build AS cloud-provider-pack +# Unstamped local builds remain usable, but cannot qualify a remote pack. +# Never invent a source revision to make an unqualified pack look verified. +RUN mkdir -p /provider-pack \ + && if [ -n "${PAPERCLIP_BUILD_COMMIT}" ]; then \ + PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}" node packages/paperclip-runner/scripts/build-provider-pack.mjs /provider-pack; \ + else \ + echo "Skipping remote provider pack: supply a full PAPERCLIP_BUILD_COMMIT to enable remote ACPX execution"; \ + fi + FROM production AS cloud +COPY --from=cloud-provider-pack /provider-pack /opt/paperclip-runner/provider-pack +# Cloud remaps node's UID at startup. This immutable pack contains public code +# and integrity metadata, never credentials; it must remain readable afterward. +# Keep it root-owned and verify access as an unrelated unprivileged UID. +RUN chmod -R a+rX /opt/paperclip-runner/provider-pack \ + && if [ -f /opt/paperclip-runner/provider-pack/provider-pack.json ]; then \ + gosu 65534:65534 node -e 'const fs = require("node:fs"); const path = require("node:path"); const root = "/opt/paperclip-runner/provider-pack"; const manifest = JSON.parse(fs.readFileSync(path.join(root, "provider-pack.json"), "utf8")); for (const artifact of Object.values(manifest.payload.artifacts)) fs.readFileSync(path.join(root, artifact.path)); fs.accessSync(path.join(root, manifest.payload.artifacts.nodeCommand.path), fs.constants.X_OK);'; \ + fi +ENV PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack COPY --chown=node:node --from=cloud-plugins /app/packages/plugins/sandbox-providers /app/packages/plugins/sandbox-providers # Land the isolated install inside the server's own `node_modules`, the # directory Node's module resolution walks up to from `/app/server` for diff --git a/doc/grok-native-runner.md b/doc/grok-native-runner.md index bc8373f4b7..1cdac172eb 100644 --- a/doc/grok-native-runner.md +++ b/doc/grok-native-runner.md @@ -3,23 +3,54 @@ 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. +New Grok runner agents default to **Full auto (approve all)** +(`acpxPermissionMode: "approve-all"`) in setup and the configuration form. +API configurations that omit the permission mode use the same default. No +additional permission setting is needed for unattended execution. Explicitly +saved restrictions remain unchanged. +On Cloud, an operator must enable `enableNativeRunner` for the instance before +the new-agent picker or direct setup page offers the native runner. 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. Isolated ask +ACP permission requests are approved by the runner under the default full-auto +policy. It does not add `--always-approve`: permission decisions remain under +the selected ACPX policy. Grok's ACP metadata cannot independently establish +Paperclip tool authority, so explicitly selecting `approve-paperclip` or +`approve-reads` returns the approval-required outcome; `deny-all` rejects requests. +Full auto does not bypass Paperclip's company permissions, governed approvals, +or execution-environment boundaries. Isolated ask rules override project allow rules, and compatible always-approve settings are locked off. Compatible hook/MCP discovery and shell login capture are disabled. ## 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. +Grok support ships inside the native runner and the public Paperclip server npm +artifact. There is no separate Grok npm package, binary payload, or npm lifecycle +download. The built-in launcher is identified as `builtin:grok-acp` version 1; +its native runtime identity is `native:grok` version 1.0.13. The historical +`agentServerPackage`/`agentRuntimePackage` wire fields carry these identities, +not npm dependencies. Existing npm-backed ACP bridges retain their package pins. + +Provision Grok Build 1.0.13 at +`/opt/paperclip/providers/grok/1.0.13/grok` in the selected execution environment. +The sandbox provisioning helper is explicit and is never run by npm: + +```sh +sudo node packages/paperclip-runner/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok +``` + +The standard Daytona image provisions it separately from the provider pack. +Custom images and local execution hosts must provide the same prerequisite. +The runner verifies the native executable checksum before credential refresh or +ACP startup; a missing prerequisite reports the required path and version. +Only macOS arm64 and Linux x64 are qualified. No ambient `grok` from PATH is used. +ACP must report the requested exact model; mismatches fail closed. + +The distribution identity change deliberately rejects resume bindings from the +former private-package profile. Start a fresh session after upgrading that +unreleased profile; do not silently reinterpret its saved identity. Instructions use Grok ACP session rules. Assigned skills live in the isolated Grok home. Steering and goals are unsupported. Token and cost values remain @@ -83,3 +114,25 @@ Do not run Docker on a developer laptop when using remote verification. The 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. + +The public npm consumer check uses a digest-pinned, unprivileged container with +no checkout or credentials mounted. It downloads dependencies with lifecycle +scripts disabled and freezes the resulting consumer lockfile. It completes the +clean install with offline `npm rebuild`, running the deferred lifecycle hooks +without re-resolving bundled optional dependencies. Networking stays disabled, +the lockfile must remain unchanged, and a sentinel proves scripts actually ran. +The pinned image includes native build tools and local Node headers so dependency +hooks can compile without network access. Both executable admission probes run +in the same isolation. The verification user provisions Grok inside the disposable +test directory without privilege elevation or host `/opt` changes. Only the +positive probe mounts that binary read-only at the canonical sandbox path. + + +The Cloud application image also carries the controller-owned provider pack and +sets `PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH`. Remote ACPX execution verifies +the sandbox against that pack before using it, or stages the matching pack when +needed. The Cloud controller image does not install the native Grok executable; +the selected sandbox image must provide the prerequisite above. +Cloud builds must supply the full source SHA through `PAPERCLIP_BUILD_COMMIT` +to produce that verified pack. Unstamped local Cloud builds still work for other +features, but omit the pack and cannot start remote ACPX sessions. diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index e3a5fa795d..75e33e9033 100644 --- a/docker/daytona-runner/Dockerfile +++ b/docker/daytona-runner/Dockerfile @@ -93,6 +93,10 @@ RUN set -eu; \ install "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh; \ rm -rf /tmp/gh.tgz /tmp/gh.sha256 "/tmp/gh_${GH_VERSION}_linux_amd64" +COPY packages/paperclip-runner/scripts/provision-grok.mjs /tmp/grok-provision/scripts/provision-grok.mjs +COPY packages/paperclip-runner/src/providers/grok/platforms.json /tmp/grok-provision/src/providers/grok/platforms.json +RUN node /tmp/grok-provision/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok && rm -rf /tmp/grok-provision + 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 \ diff --git a/docker/daytona-runner/README.md b/docker/daytona-runner/README.md index f262fa72cd..54b8911a67 100644 --- a/docker/daytona-runner/README.md +++ b/docker/daytona-runner/README.md @@ -108,5 +108,9 @@ lock against this value before installation. The fixed Dockerfile default is for standalone builds; it must not replace a campaign's verified lock digest. Refresh exact runtime versions and qualification digests together; never download dependencies when a task starts. Grok's additive native ACP profile keeps its -qualified 1.0.13 executable at a verified package path. It does not replace the +qualified 1.0.13 executable at the verified sandbox prerequisite path. It does not replace the legacy adapter's `grok` command on PATH. + +Native Grok is an image prerequisite at `/opt/paperclip/providers/grok/1.0.13/grok`. +Its checksum-verified provisioning is separate from the provider pack, which ships +only the built-in launcher. Public npm installation never downloads this binary. diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index b9338a3e5c..a431aea47a 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -36,6 +36,37 @@ function makeValues(overrides: Partial = {}): CreateConfigVa } describe("buildCodexLocalConfig", () => { + it.each([undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"])( + "defaults Grok to full auto while preserving an explicit %s permission mode", + (acpxPermissionMode) => { + const config = buildPaperclipRunnerConfig(makeValues({ + adapterType: "paperclip_runner", + model: "", + adapterSchemaValues: { provider: "acpx", acpxAgent: "grok", acpxPermissionMode }, + })); + expect(config).toMatchObject({ + provider: "acpx", + acpxAgent: "grok", + model: "grok-4.7", + acpxPermissionMode: acpxPermissionMode ?? "approve-all", + }); + }, + ); + + it.each(["", "grok-4.7-custom"])("retains the Grok harness and its model when normalizing runner fields (%s)", (model) => { + const values = makeValues({ + model, + adapterSchemaValues: { provider: "acpx", acpxAgent: "grok", acpxPermissionMode: "approve-paperclip" }, + }); + expect(buildPaperclipRunnerConfig(values)).toMatchObject({ + provider: "acpx", + acpxAgent: "grok", + model: model || "grok-4.7", + acpxPermissionMode: "approve-paperclip", + }); + expect(values.adapterSchemaValues?.acpxAgent).toBe("grok"); + }); + it("omits engine for the auto default so runtime fallback remains available", () => { const config = buildCodexLocalConfig(makeValues({ codexEngine: "auto" })); diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index 25f2010392..eb381bb789 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -95,6 +95,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record=24.11.0" - } -} diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 9e60522c58..a98643fad4 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -96,6 +96,13 @@ do not change workspace isolation or grant credentials or connection access. task tools; `approve-reads` allows assigned reads; `deny-all` rejects requests. None of these restrictive modes is the default. +Automatic Paperclip/read allowances currently require the Claude SDK dispatch +boundary. Grok preserves these restricted settings, but its ACP requests lack +independently bound tool authority. They therefore stop with +`approval_required`, including Paperclip tool requests. Use an explicitly +selected `approve-all` policy for unattended Grok work in an assigned sandbox; +Paperclip authorization and governed approvals still apply. + This runtime has no interactive permission handler. An operation that still requires approval stops the turn with `approval_required`. The server marks the task blocked, exposes the permission action to the operator, and disables diff --git a/packages/paperclip-runner/docs/adding-a-harness.md b/packages/paperclip-runner/docs/adding-a-harness.md index 1b5251f188..9f082a503d 100644 --- a/packages/paperclip-runner/docs/adding-a-harness.md +++ b/packages/paperclip-runner/docs/adding-a-harness.md @@ -5,13 +5,39 @@ the provider contract. A driver is not complete until its permission modes, maximum non-interactive default, durable request translation, recovery identity, isolation behavior, and conformance coverage are defined. +## Distribution + +First-party harness glue belongs in the runner, not in a new npm package for +each provider. Keep built-in launchers and metadata under +`src/providers//`; include them in the compiled runner and the public +server's vendored runtime. Grok is the reference implementation. This keeps a +public Paperclip npm install self-contained as more harnesses are added. + +Native provider binaries remain execution-environment prerequisites. Provision +them explicitly in sandbox images or on local execution hosts, independently +of npm installation and the controller provider pack. Admission must verify +the qualified version and executable checksum before credentials are delivered. +Missing prerequisites should produce an actionable error, never a silent +download or an unverified fallback to PATH. + +Use a distinct built-in launcher identity and native runtime identity in the +qualified profile, recovery binding, provider-pack manifest, and eval config. +Existing upstream npm-backed ACP bridges retain their package pins; this does +not require replacing those bridges or republishing them under Paperclip names. + +Verify public release tarballs in a clean consumer outside the checkout with +npm lifecycle scripts enabled. Prove that the launcher ships, npm does not +install the native prerequisite, missing prerequisites fail closed, and +explicit provisioning permits verified execution. See +`scripts/verify-grok-npm-install.mjs` at the repository root. + ## Current permission catalog | Provider | Agent configuration key | Supported values | Default | |---|---|---|---| | Codex | `codexPermissionMode` | `never`, `on-request`, `untrusted` | `never` | | OpenCode | `opencodePermissionMode` | `allow`, `ask`, `deny` | `allow` | -| ACPX (Claude, Codex) | `acpxPermissionMode` | `approve-all`, `approve-paperclip`, `approve-reads`, `deny-all` | `approve-all` | +| ACPX (Claude, Codex, Grok) | `acpxPermissionMode` | `approve-all`, `approve-paperclip`, `approve-reads`, `deny-all` | `approve-all` | The browser-safe source of truth for labels, defaults, and configuration validation is `PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES` in diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 8ab714ec92..ebc0614e39 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -160,7 +160,6 @@ "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", - "install:grok": "pnpm --filter @paperclipai/grok-acp install:binary", "test:opencode:qualification": "PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs" }, "dependencies": { @@ -173,8 +172,7 @@ "opencode-ai": "1.18.32", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", - "smol-toml": "^1.4.2", - "@paperclipai/grok-acp": "workspace:*" + "smol-toml": "^1.4.2" }, "peerDependencies": { "react": ">=18", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 22581e7d2a..626deef153 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -164,9 +164,9 @@ impl AcpxProviderDescriptor { ), "grok" => ( "grok-4.7", - "@paperclipai/grok-acp", - "1.0.13", - Some("@paperclipai/grok-acp"), + "builtin:grok-acp", + "1", + Some("native:grok"), Some("1.0.13"), "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", ), diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 409b87cf35..f524d8a296 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -124,6 +124,7 @@ impl AcpxSidecarTransport { // The qualified sidecar configures the runner-owned gateway. Keep // its credential with the name/URL; unrelated secrets stay excluded. "PAPERCLIP_NATIVE_MCP_TOKEN", + "PAPERCLIP_ACPX_BUILTIN_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", ]; diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index 03e5ede50a..167051e0be 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -188,12 +188,6 @@ 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"); - // Native ACP launches the verified absolute package path. Do not expose a - // generic grok shim: the legacy adapter owns that command and its version. - writePortableNodeShim("paperclip-grok-acp", "@paperclipai/grok-acp/launcher.cjs"); writePortableNodeShim("acpx", "acpx/dist/cli.js"); writePortableNodeShim( "claude-agent-acp", @@ -321,9 +315,9 @@ try { "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", }, artifacts: { - grokExecutable: { - path: "node_modules/@paperclipai/grok-acp/bin/grok", - sha256: sha256File(join(grokPackage, "bin/grok")), + grokLauncher: { + path: "dist/providers/grok/launcher.cjs", + sha256: sha256File(join(temporaryRoot, "dist/providers/grok/launcher.cjs")), }, nodeCommand: { path: nodeCommand, diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs index acf7b56083..a3a2aa9f8c 100644 --- a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs @@ -1,4 +1,4 @@ -import { chmod } from "node:fs/promises"; +import { chmod, cp, mkdir } from "node:fs/promises"; import { builtinModules } from "node:module"; import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -50,6 +50,10 @@ function assertSelfContainedBundle(entrypoint, result) { } export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) { + if (write) { + await mkdir(resolve(packageRoot, "dist/providers"), { recursive: true }); + await cp(resolve(packageRoot, "src/providers"), resolve(packageRoot, "dist/providers"), { recursive: true }); + } const results = []; for (const entrypoint of verifiedProviderEntrypoints) { const buildBundle = async (outfile, format) => { diff --git a/packages/paperclip-runner/scripts/grok-native-smoke.mjs b/packages/paperclip-runner/scripts/grok-native-smoke.mjs index 0ebfd3dc78..7bb761ab9a 100644 --- a/packages/paperclip-runner/scripts/grok-native-smoke.mjs +++ b/packages/paperclip-runner/scripts/grok-native-smoke.mjs @@ -30,7 +30,7 @@ if (values.auth === 'api') { 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)); +const binary = await readFile('/opt/paperclip/providers/grok/1.0.13/grok'); 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(); diff --git a/packages/grok-acp/install.mjs b/packages/paperclip-runner/scripts/provision-grok.mjs similarity index 70% rename from packages/grok-acp/install.mjs rename to packages/paperclip-runner/scripts/provision-grok.mjs index 009c24a451..657a98d643 100644 --- a/packages/grok-acp/install.mjs +++ b/packages/paperclip-runner/scripts/provision-grok.mjs @@ -1,11 +1,12 @@ import { createHash } from "node:crypto"; import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; +import { dirname, isAbsolute } from "node:path"; import { gunzipSync } from "node:zlib"; -const manifest = JSON.parse(await readFile(new URL("./platforms.json", import.meta.url), "utf8")); +const manifest = JSON.parse(await readFile(new URL("../src/providers/grok/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 destination = process.argv[2]; +if (!destination || !isAbsolute(destination)) throw new Error("Provisioning requires an explicit absolute destination; this script is never an npm lifecycle hook"); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); const installed = await readFile(destination).catch(() => null); if (!installed || digest(installed) !== platform.sha256) { @@ -14,12 +15,12 @@ if (!installed || digest(installed) !== platform.sha256) { 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 }); + await mkdir(dirname(destination), { recursive: true }); const temporary = `${destination}.${process.pid}.tmp`; try { - await writeFile(temporary, bytes, { flag: "wx", mode: 0o700 }); + await writeFile(temporary, bytes, { flag: "wx", mode: 0o755 }); await rename(temporary, destination); } finally { await rm(temporary, { force: true }); } } -await chmod(destination, 0o700); +await chmod(destination, 0o755); console.log(`Verified Grok ${manifest.version} (${process.platform}-${process.arch})`); diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index fd1c771487..5e4d9631f9 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -3303,6 +3303,7 @@ const runnerExplicitProviderEnvironmentKeys = [ "PAPERCLIP_NATIVE_MCP_TOKEN", "PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH", "PAPERCLIP_RUNNER_EXTERNAL_SANDBOX", + "PAPERCLIP_ACPX_BUILTIN_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST", "PAPERCLIP_ACPX_PROVIDER_RECOVERY_POLICY", diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts index 963255639a..b97b7c51ac 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -24,6 +24,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js"; import { + verifyProvisionedGrokExecutable, + builtinGrokLauncherPath, awaitVerifiedAcpxProviderExit, awaitVerifiedAcpxProviderOwnership, createAcpxPackageJsonResolver, @@ -607,18 +609,38 @@ describe("ACPX installation integrity", () => { }, ); + it("resolves the shipped builtin launcher without an npm package", async () => { + const launcher = builtinGrokLauncherPath(); + expect(launcher).toContain("/providers/grok/launcher.cjs"); + expect(`sha256:${createHash("sha256").update(await readFile(launcher)).digest("hex")}`) + .toBe(resolveQualifiedAcpxProfile("grok", "grok-4.7").commandDigest); + expect(resolveQualifiedAcpxProfile("grok", "grok-4.7").agentServerPackage).toBe("builtin:grok-acp"); + }); + + it("reports a missing environment prerequisite before launching Grok", async () => { + const fixture = await installationFixture(); + await expect(verifyProvisionedGrokExecutable(join(fixture.commandDirectory, "missing"))) + .rejects.toThrow(/Grok Build 1.0.13 prerequisite missing/); + }); + + it("resolves a controller-owned builtin root for descriptor-loaded sidecars and rejects relative roots", () => { + vi.stubEnv("PAPERCLIP_ACPX_BUILTIN_ROOT", "/verified/dist/providers"); + try { expect(builtinGrokLauncherPath("file:///proc/self/fd/9")).toBe("/verified/dist/providers/grok/launcher.cjs"); } + finally { vi.unstubAllEnvs(); } + vi.stubEnv("PAPERCLIP_ACPX_BUILTIN_ROOT", "../untrusted"); + try { expect(() => builtinGrokLauncherPath()).toThrow("Invalid builtin provider root"); } + finally { vi.unstubAllEnvs(); } + }); + 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 expect(verifyProvisionedGrokExecutable(native)).rejects.toThrow(/digest mismatch/); await rm(native); await symlink(fixture.commandPath, native); - await expect(verifyQualifiedAcpxInstallation(profile, resolvePackage)).rejects.toThrow(/regular file|symlink|no-follow/); + await expect(verifyProvisionedGrokExecutable(native)).rejects.toThrow(/regular file|symlink|no-follow/); }); it("rejects package version and executable digest drift", async () => { @@ -2119,7 +2141,7 @@ async function installationFixture() { describe("Grok launcher subscription refresh", () => { it("keeps the Grok launcher digest synchronized across TypeScript, Rust, server, and provider pack", async () => { - const launcher = await readFile(new URL("../../../../grok-acp/launcher.cjs", import.meta.url)); + const launcher = await readFile(new URL("../../providers/grok/launcher.cjs", import.meta.url)); const digest = `sha256:${createHash("sha256").update(launcher).digest("hex")}`; expect(resolveQualifiedAcpxProfile("grok", "grok-4.7").commandDigest).toBe(digest); const [server, pack, rust] = await Promise.all([ @@ -2137,7 +2159,7 @@ describe("Grok launcher subscription refresh", () => { refresh?: { status: number | null; signal?: string | null; error?: Error }; mode?: number; fileError?: string; growingFile?: boolean; } = {}) { - const script = await readFile(new URL("../../../../grok-acp/launcher.cjs", import.meta.url), "utf8"); + const script = await readFile(new URL("../../providers/grok/launcher.cjs", import.meta.url), "utf8"); const payload = Buffer.from(JSON.stringify({ account: { key: "PRIVATE-CREDENTIAL-SENTINEL", refresh_token: "PRIVATE-REFRESH-SENTINEL", expires_at: "rawExpiry" in input ? input.rawExpiry : new Date(input.expiry ?? Date.now() - 60_000).toISOString(), diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index 5f2c2df7bc..b4a954fb07 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -5,7 +5,7 @@ import { type ChildProcess, type SpawnOptionsWithoutStdio, } from "node:child_process"; -import { constants, realpathSync } from "node:fs"; +import { constants, existsSync, realpathSync } from "node:fs"; import { lstat, open, @@ -15,6 +15,7 @@ import { type FileHandle, } from "node:fs/promises"; import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; import { basename, dirname, @@ -342,13 +343,7 @@ export function createAcpxPackageJsonResolver( const packageJsonPath = realpathSync( resolvePackageJsonFromIssuer(packageName, canonicalIssuer), ); - // 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) { + if (!pathIsInside(canonicalNodeModules, packageJsonPath)) { throw new Error( `ACPX provider package ${packageName} resolves outside the selected provider root`, ); @@ -407,7 +402,7 @@ function pathIsInside(root: string, candidate: string): boolean { export interface VerifiedAcpxInstallation { readonly commandDigest: string; - readonly agentServerPackageJsonPath: string; + readonly agentServerPackageJsonPath: string | null; readonly agentRuntimePackageJsonPath: string | null; openCommand(): Promise; } @@ -529,13 +524,14 @@ export async function verifyQualifiedAcpxInstallation( profile: QualifiedAcpxProfile, resolvePackageJson: AcpxPackageJsonResolver = defaultPackageJsonResolver, ): Promise { - const serverPackageJsonPath = await realpath( - resolvePackageJson(profile.agentServerPackage), - ); - const serverPackage = await readPackageJson( - serverPackageJsonPath, - profile.agentServerPackage, - ); + const builtin = profile.agent === "grok"; + if (builtin && (profile.agentServerPackage !== "builtin:grok-acp" || profile.agentServerVersion !== "1" || profile.agentRuntimePackage !== "native:grok" || profile.agentRuntimeVersion !== "1.0.13")) { + throw new Error("Grok builtin profile identity mismatch"); + } + const serverPackageJsonPath = builtin ? null : await realpath(resolvePackageJson(profile.agentServerPackage)); + const serverPackage: AcpxPackageMetadata = builtin + ? { version: "1", bin: "launcher.cjs", type: "commonjs" } + : await readPackageJson(serverPackageJsonPath!, profile.agentServerPackage); if (serverPackage.version !== profile.agentServerVersion) { throw new Error( `ACPX ${profile.agent} package version mismatch: expected ${profile.agentServerVersion}, received ${serverPackage.version ?? "unknown"}`, @@ -548,7 +544,7 @@ export async function verifyQualifiedAcpxInstallation( profile.agent, ); const serverPackageFormat = packageModuleFormat(serverPackage.type); - const packageDirectory = dirname(serverPackageJsonPath); + const packageDirectory = builtin ? await realpath(dirname(builtinGrokLauncherPath())) : dirname(serverPackageJsonPath!); const unresolvedCommandPath = resolve(packageDirectory, relativeCommand); if (!isInside(packageDirectory, unresolvedCommandPath)) { throw new Error(`ACPX ${profile.agent} executable escapes its package`); @@ -577,12 +573,14 @@ export async function verifyQualifiedAcpxInstallation( let runtimePackageFormat: AcpxCommandFormat | null = null; let runtimePackage: AcpxPackageMetadata | null = null; let runtimeExecutable: VerifiedAcpxRuntimeExecutable | null = null; - if (profile.agentRuntimePackage !== null) { + if (builtin) { + runtimeExecutable = await verifyProvisionedGrokExecutable(); + } else if (profile.agentRuntimePackage !== null) { if (profile.agentRuntimeVersion === null) { throw new Error("Qualified ACPX runtime package omitted its version"); } runtimePackageJsonPath = await realpath( - resolvePackageJson(profile.agentRuntimePackage, serverPackageJsonPath), + resolvePackageJson(profile.agentRuntimePackage, serverPackageJsonPath!), ); runtimePackage = await readPackageJson( runtimePackageJsonPath, @@ -628,7 +626,7 @@ export async function verifyQualifiedAcpxInstallation( ); } const dependencyPackageJsonPath = await realpath( - resolvePackageJson(expected.packageName, serverPackageJsonPath), + resolvePackageJson(expected.packageName, serverPackageJsonPath!), ); const dependencyPackage = await readPackageJson( dependencyPackageJsonPath, @@ -838,25 +836,44 @@ async function readPackageJson( return value as AcpxPackageMetadata; } +// Same layout in source, compiled modules, bundled sidecar, and vendored npm output. +export function builtinGrokLauncherPath(moduleUrl: string = import.meta.url): string { + // Only the controller supplies this path to the descriptor-loaded sidecar; + // createSanitizedAcpxSpawnInput excludes it from provider environments. + const root = process.env.PAPERCLIP_ACPX_BUILTIN_ROOT; + if (root !== undefined) { + if (!isAbsolute(root) || root.includes("\0") || resolve(root) !== root) throw new Error("Invalid builtin provider root"); + return resolve(root, "grok/launcher.cjs"); + } + for (const relativePath of ["../../providers/grok/launcher.cjs", "../providers/grok/launcher.cjs"]) { + const candidate = fileURLToPath(new URL(relativePath, moduleUrl)); + if (existsSync(candidate)) return candidate; + } + throw new Error("Grok builtin launcher is missing from the Paperclip installation"); +} + +export const GROK_PREREQUISITE_PATH = "/opt/paperclip/providers/grok/1.0.13/grok"; + +export async function verifyProvisionedGrokExecutable(executablePath = GROK_PREREQUISITE_PATH): Promise { + const digests: Record = { + "darwin-arm64": "8669e0fdadceec25b8c159c355f427ffbd82583525d774b6ab1522197ea83b80", + "linux-x64": "edf79521581bb5e6b95abef848491a6a742e860da3e237ebe86a280d30dce4c1", + }; + const digest = digests[`${process.platform}-${process.arch}`]; + if (!digest) throw new Error(`Grok prerequisite unavailable for ${process.platform}-${process.arch}`); + if (!existsSync(executablePath)) throw new Error(`Grok Build 1.0.13 prerequisite missing: provision ${GROK_PREREQUISITE_PATH} in the execution environment`); + 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" }; +} + async function verifyQualifiedRuntimeExecutable(input: { profile: QualifiedAcpxProfile; runtimePackage: AcpxPackageMetadata; runtimePackageJsonPath: string; resolvePackageJson: AcpxPackageJsonResolver; }): Promise { - if (input.profile.agent === "grok") { - const digests: Record = { - "darwin-arm64": "8669e0fdadceec25b8c159c355f427ffbd82583525d774b6ab1522197ea83b80", - "linux-x64": "edf79521581bb5e6b95abef848491a6a742e860da3e237ebe86a280d30dce4c1", - }; - const digest = digests[`${process.platform}-${process.arch}`]; - if (!digest) throw new Error(`Grok verified runtime unavailable for ${process.platform}-${process.arch}`); - const executablePath = resolve(dirname(input.runtimePackageJsonPath), "bin/grok"); - const verified = await openVerifiedRuntimeExecutable(executablePath, `sha256:${digest}`, "grok"); - await verified.handle.close(); - return { path: executablePath, digest: `sha256:${digest}`, identity: verified.identity, - environmentVariable: "PAPERCLIP_GROK_VERIFIED_EXECUTABLE" }; - } const qualification = input.profile.agent === "claude" ? process.platform === "darwin" && (process.arch === "arm64" || process.arch === "x64") diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 0d33811be6..944fe433ac 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -12,6 +12,7 @@ export interface QualifiedAcpxProfile { readonly acpxVersion: typeof QUALIFIED_ACPX_VERSION; readonly agent: QualifiedAcpxAgent; readonly agentProfileVersion: 1; + /** Wire identity: an npm package name or a runner-owned builtin: identifier. */ readonly agentServerPackage: string; readonly agentServerVersion: string; readonly agentRuntimePackage: string | null; @@ -34,8 +35,8 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< 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", + agentServerPackage: "builtin:grok-acp", agentServerVersion: "1", + agentRuntimePackage: "native:grok", agentRuntimeVersion: "1.0.13", commandDigest: "sha256:f0b698395a3704ed2ffaf84ea19bdb20c36c8a0a70b7c629c7b6ffe144e59e55", qualificationModel: "grok-4.7", reportedModelId: "grok-4.7", permissionPolicy: "interactive", }, diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 9fbb2e09da..691dda544f 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -1370,6 +1370,15 @@ it("derives the ACPX package authority only from the verified dist/cli layout", ).toThrow("ACPX sidecar must use the provider package dist/cli layout"); }); +it("uses the public server npm package as the authority for vendored sidecars", () => { + expect(runnerdLaunchProfileInternals.acpxProviderPackageAuthority( + "/clean/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/cli/acpx-runtime-sidecar.cjs", + )).toEqual({ + root: "/clean/node_modules/@paperclipai/server", + manifest: "/clean/node_modules/@paperclipai/server/package.json", + }); +}); + it("keeps a self-rooted pnpm deployment inside its dependency authority", async () => { const deploymentRoot = await mkdtemp( join(tmpdir(), "paperclip-deployed-provider-root-"), @@ -1722,6 +1731,7 @@ it.each([ environment: { PATH: "/bin", ...credentialEnvironment, + PAPERCLIP_ACPX_BUILTIN_ROOT: "/attacker/builtin", PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/attacker/package-root", PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST: "/attacker/package-root/package.json", @@ -1750,6 +1760,7 @@ it.each([ PAPERCLIP_RUN_ID: "run-1", PAPERCLIP_NORMALIZED_SESSION_ID: "session-1", PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH: "/isolated/runtime-context.json", + PAPERCLIP_ACPX_BUILTIN_ROOT: "/verified/provider-pack/dist/providers", PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: "/verified/provider-pack", PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST: "/verified/provider-pack/package.json", diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 70d3b52a0c..1b35308400 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -2874,6 +2874,15 @@ function acpxProviderPackageAuthority( manifest: string; } { const cliDirectory = dirname(sidecarScript); + // Public server packages vendor runner dist directly, without a nested dist + // directory or a separately published runner package. + if (basename(sidecarScript) === "acpx-runtime-sidecar.cjs" && + basename(cliDirectory) === "cli" && basename(dirname(cliDirectory)) === "paperclip-runner" && + basename(resolve(cliDirectory, "../..")) === "vendor" && + basename(resolve(cliDirectory, "../../..")) === "dist") { + const serverRoot = resolve(cliDirectory, "../../../.."); + return { root: serverRoot, manifest: resolve(serverRoot, "package.json") }; + } if ( basename(sidecarScript) !== "acpx-runtime-sidecar.cjs" || basename(cliDirectory) !== "cli" || @@ -3144,6 +3153,7 @@ export function createCapabilityRunnerdProviderEnvironment(input: { // The verified sidecar bundle cannot use import.meta.url while Node // executes it through /proc/self/fd. Anchor its closed provider package // lookups at the package that owns the already-authenticated bundle. + PAPERCLIP_ACPX_BUILTIN_ROOT: resolve(dirname(sidecarPath), "../providers"), PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT: providerPackageAuthority.root, PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST: providerPackageAuthority.manifest, diff --git a/packages/grok-acp/launcher.cjs b/packages/paperclip-runner/src/providers/grok/launcher.cjs similarity index 100% rename from packages/grok-acp/launcher.cjs rename to packages/paperclip-runner/src/providers/grok/launcher.cjs diff --git a/packages/grok-acp/platforms.json b/packages/paperclip-runner/src/providers/grok/platforms.json similarity index 100% rename from packages/grok-acp/platforms.json rename to packages/paperclip-runner/src/providers/grok/platforms.json diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs new file mode 100644 index 0000000000..316b417d31 --- /dev/null +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs } from '../grok-public-install-sandbox.mjs'; + +const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 }; +const values = (args, flag) => args.flatMap((value, index) => value === flag ? [args[index + 1]] : []); + +test('lifecycle execution has no network, host credentials, checkout, or elevated privileges', () => { + const args = grokConsumerDockerArgs({ ...paths, command: GROK_PUBLIC_INSTALL_LIFECYCLE }); + assert.deepEqual(values(args, '--network'), ['none']); + assert.deepEqual(values(args, '--user'), ['1001:1001']); + assert.ok(args.includes('--read-only')); + assert.deepEqual(values(args, '--cap-drop'), ['ALL']); + assert.deepEqual(values(args, '--security-opt'), ['no-new-privileges']); + assert.deepEqual(values(args, '--mount'), [ + 'type=bind,src=/private/staging/assets,dst=/packages,readonly', + 'type=bind,src=/private/staging/consumer,dst=/consumer', + 'type=bind,src=/private/staging/cache,dst=/cache', + ]); + assert.deepEqual(values(args, '--env'), ['HOME=/tmp', 'npm_config_cache=/cache', 'npm_config_nodedir=/usr/local', 'npm_config_audit=false', 'npm_config_fund=false', 'npm_config_ignore_scripts=false']); + assert.match(GROK_PUBLIC_INSTALL_IMAGE, /@sha256:[a-f0-9]{64}$/); +}); + +test('deferred lifecycle execution rebuilds the installed graph without dependency resolution', () => { + assert.deepEqual(GROK_PUBLIC_INSTALL_LIFECYCLE, ['npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts']); +}); + +test('a root or malformed host identity cannot run lifecycle scripts', () => { + for (const uid of [0, -1, undefined, '1001']) { + assert.throws(() => grokConsumerDockerArgs({ ...paths, uid, command: ['npm', 'ci'] }), /unprivileged/); + } +}); + +test('only the scripts-disabled dependency download gets network access', () => { + const args = grokConsumerDockerArgs({ ...paths, download: true, command: ['npm', 'install', '--ignore-scripts'] }); + assert.deepEqual(values(args, '--network'), ['bridge']); + assert.ok(values(args, '--env').includes('npm_config_ignore_scripts=true')); +}); + +test('the separately provisioned executable is exposed read-only to the offline probe', () => { + const prerequisite = '/private/staging/native/grok'; + const args = grokConsumerDockerArgs({ ...paths, prerequisite, command: ['node', '/packages/probe.mjs', 'present'] }); + assert.deepEqual(values(args, '--network'), ['none']); + assert.equal(values(args, '--mount').at(-1), `type=bind,src=${prerequisite},dst=/opt/paperclip/providers/grok/1.0.13/grok,readonly`); +}); + +test('verification never elevates PR-controlled provisioning or cleanup on the host', () => { + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + assert.doesNotMatch(source, /\bsudo\b/); + assert.ok(source.includes("const prerequisite = join(root, 'native/grok')")); +}); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 0ddf46d9cc..293280c2d1 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -436,8 +436,8 @@ test("Runner eval workflows pin actions and gate paid live execution", () => { test("direct Grok qualification installs the pinned binary and scopes the selected credential", () => { const workflow = readWorkflow("runner-protocol-live-evals.yml"); assert.ok(workflow.includes("XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }}")); - assert.ok(workflow.includes("if [ -f packages/grok-acp/install.mjs ]; then")); - assert.ok(workflow.indexOf("node packages/grok-acp/install.mjs") < workflow.indexOf("pnpm --filter @paperclipai/paperclip-runner deploy --prod")); + assert.ok(workflow.includes("if [ -f packages/paperclip-runner/scripts/provision-grok.mjs ]; then")); + assert.ok(workflow.indexOf("sudo node packages/paperclip-runner/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok") < workflow.indexOf("pnpm --filter @paperclipai/paperclip-runner deploy --prod")); assert.ok(workflow.includes("PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET: ${{ matrix.credentialName == 'PAPERCLIP_ACPX_GROK_AUTH_JSON_SECRET' && secrets.GROK_AUTH_JSON || '' }}")); assert.equal((workflow.match(/secrets\.GROK_AUTH_JSON/gu) ?? []).length, 1); }); diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs new file mode 100644 index 0000000000..c86eaf9f51 --- /dev/null +++ b/scripts/grok-public-install-sandbox.mjs @@ -0,0 +1,31 @@ +// Keep public-package lifecycle code off the verification host. Resolve and +// cache the public npm graph without scripts, then execute it offline. +export const GROK_PUBLIC_INSTALL_IMAGE = + 'node:24-trixie@sha256:be40f6a87b9b22215ddb20da0a2320a5c6d583fe3ee3b0024d9fa4f05b40c8fd'; +// Complete the scripts-disabled install without resolving the graph again. +export const GROK_PUBLIC_INSTALL_LIFECYCLE = [ + 'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', +]; + +export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite }) { + if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) { + throw new Error('Public-install verification requires an unprivileged host user'); + } + return [ + 'run', '--rm', '--platform', 'linux/amd64', + '--user', `${uid}:${gid}`, '--read-only', + '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', + '--pids-limit', '256', '--memory', '3g', + '--network', download ? 'bridge' : 'none', + '--tmpfs', '/tmp:rw,nosuid,nodev,size=256m,mode=1777', + '--env', 'HOME=/tmp', '--env', 'npm_config_cache=/cache', + '--env', 'npm_config_nodedir=/usr/local', + '--env', 'npm_config_audit=false', '--env', 'npm_config_fund=false', + '--env', `npm_config_ignore_scripts=${download ? 'true' : 'false'}`, + '--mount', `type=bind,src=${assets},dst=/packages,readonly`, + '--mount', `type=bind,src=${consumer},dst=/consumer`, + '--mount', `type=bind,src=${cache},dst=/cache`, + ...(prerequisite ? ['--mount', `type=bind,src=${prerequisite},dst=/opt/paperclip/providers/grok/1.0.13/grok,readonly`] : []), + '--workdir', '/consumer', GROK_PUBLIC_INSTALL_IMAGE, ...command, + ]; +} diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs new file mode 100644 index 0000000000..ee1788f9df --- /dev/null +++ b/scripts/verify-grok-npm-install.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// Run on a disposable Linux verification host after pnpm build. No publication, +// credentials, inference, or changes to release versions. Native provisioning is +// explicit and separate from npm installation, and is removed in finally. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { materializePublishManifest, prepareBundledPackage } from './prepare-bundled-package.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, grokConsumerDockerArgs } from './grok-public-install-sandbox.mjs'; +const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +assert.equal(process.platform, 'linux', 'Run this verification on disposable EC2 Linux, not a developer host'); +const root = mkdtempSync(join(tmpdir(), 'paperclip-grok-public-install-')); +const prerequisite = join(root, 'native/grok'); +const env = { ...process.env, NODE_PATH: '', PAPERCLIP_RELEASE_REUSE_UI_DIST: '1', npm_config_ignore_scripts: 'false', npm_config_audit: 'false', npm_config_fund: 'false' }; +const run = (cmd, args, cwd = root) => execFileSync(cmd, args, { cwd, env, stdio: 'pipe', maxBuffer: 32 * 1024 * 1024 }); +const sourceRevision = run('git', ['rev-parse', 'HEAD'], repo).toString().trim(); +const releaseVersion = `0.0.0-grok-verify.${sourceRevision.slice(0, 12)}`; +try { + const listing = run(process.execPath, [join(repo, 'scripts/release-package-map.mjs'), 'list'], repo).toString().trim().split('\n').map(line => line.split('\t')); + const packages = new Map(listing.map(([dir, name]) => [name, { dir, manifest: JSON.parse(readFileSync(join(repo, dir, 'package.json'), 'utf8')) }])); + const needed = new Set(); + function visit(name) { + if (needed.has(name)) return; + const entry = packages.get(name); assert.ok(entry, `Missing public workspace dependency ${name}`); needed.add(name); + for (const [dep, spec] of Object.entries({ ...entry.manifest.dependencies, ...entry.manifest.optionalDependencies })) { + if (spec.startsWith('workspace:')) visit(dep); + } + } + visit('@paperclipai/server'); + // Match release.sh's unified versioning in temporary staging directories. + // Source manifests remain untouched, including independently versioned SDKs. + run(process.execPath, [join(repo, 'scripts/build-standalone-public-packages.mjs')], repo); + run('bash', [join(repo, 'scripts/prepare-server-ui-dist.sh')], repo); + const tarballs = []; + for (const [index, name] of [...needed].entries()) { + const { dir, manifest } = packages.get(name); + const target = join(root, `package-${index}`); mkdirSync(target); + const stagedSource = join(root, `source-${index}`); mkdirSync(stagedSource); + for (const file of manifest.files ?? ['dist']) { + // release.sh stages runtime skills into these public packages before pack. + const releaseSkills = file === 'skills' && ['server', 'packages/adapters/claude-local', 'packages/adapters/codex-local'].includes(dir); + // Other adapters declare an optional skills directory that npm pack omits + // when absent. Do not fabricate extra release payloads for those adapters. + if (file === 'skills' && !releaseSkills && !existsSync(join(repo, dir, file))) continue; + cpSync(releaseSkills ? join(repo, 'skills') : join(repo, dir, file), join(stagedSource, file), { recursive: true }); + } + const releaseManifest = { ...manifest, version: releaseVersion }; + writeFileSync(join(stagedSource, 'package.json'), JSON.stringify(releaseManifest)); + if ((manifest.bundleDependencies ?? manifest.bundledDependencies ?? []).length) { + prepareBundledPackage(stagedSource, target); + } else { + cpSync(stagedSource, target, { recursive: true }); + writeFileSync(join(target, 'package.json'), JSON.stringify(materializePublishManifest(releaseManifest))); + } + run('npm', ['pack', '--ignore-scripts', '--pack-destination', root], target); + const packed = readdirSync(root).filter(f => f.endsWith('.tgz') && !tarballs.includes(join(root, f))); + assert.equal(packed.length, 1); tarballs.push(join(root, packed[0])); + } + const assets = join(root, 'assets'); mkdirSync(assets, { mode: 0o755 }); + const consumer = join(root, 'consumer'); mkdirSync(consumer); + const cache = join(root, 'cache'); mkdirSync(cache); + writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' })); + for (const tarball of tarballs) { + const destination = join(assets, basename(tarball)); cpSync(tarball, destination); chmodSync(destination, 0o644); + } + // Prove lifecycle execution is real even if npm changes its script defaults. + const sentinelSource = join(root, 'lifecycle-sentinel'); mkdirSync(sentinelSource); + writeFileSync(join(sentinelSource, 'package.json'), JSON.stringify({ + name: 'paperclip-verification-lifecycle-sentinel', version: '1.0.0', private: true, + scripts: { postinstall: 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"' }, + })); + run('npm', ['pack', '--ignore-scripts', '--pack-destination', assets], sentinelSource); + const sentinel = join(consumer, 'node_modules/paperclip-verification-lifecycle-sentinel/lifecycle-ran'); + const consumerUid = process.getuid(); + const isolated = (command, options = {}) => run('docker', grokConsumerDockerArgs({ assets, consumer, cache, uid: consumerUid, gid: process.getgid(), command, ...options })); + // npm resolution is intentionally the public consumer graph, not the pnpm + // workspace graph. Freeze that result before any lifecycle script can run. + isolated(['npm', 'install', '--ignore-scripts', '--omit=dev', ...readdirSync(assets).filter(file => file.endsWith('.tgz')).map(file => `/packages/${file}`)], { download: true }); + assert.equal(existsSync(sentinel), false, 'Dependency download must not run lifecycle scripts'); + const consumerLock = readFileSync(join(consumer, 'package-lock.json'), 'utf8'); + // npm ci rejects bundled optional platform dependencies absent from its own + // generated lock. Rebuild runs the deferred install hooks on the installed + // graph without re-resolving it; network isolation and lock checks still hold. + isolated(GROK_PUBLIC_INSTALL_LIFECYCLE); + assert.equal(readFileSync(sentinel, 'utf8'), 'ok', 'Offline lifecycle scripts must actually execute'); + assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), consumerLock, 'Lifecycle execution must preserve the resolved consumer lock'); + for (const name of needed) { + const installedManifest = JSON.parse(readFileSync(join(consumer, 'node_modules', name, 'package.json'), 'utf8')); + assert.equal(installedManifest.version, releaseVersion, `Installed release version for ${name}`); + } + assert.equal(existsSync(prerequisite), false, 'npm must not provision Grok'); + const server = join(consumer, 'node_modules/@paperclipai/server'); + const installed = join(server, 'dist/vendor/paperclip-runner'); + assert.ok(existsSync(join(installed, 'providers/grok/launcher.cjs'))); + assert.equal(existsSync(join(consumer, 'node_modules/@paperclipai/grok-acp')), false); + assert.equal(existsSync(join(installed, 'providers/grok/bin')), false); + // Use real installed compiled code and its actual npm dependency graph. A + // separate process prevents module resolution from borrowing this checkout. + const probe = ` + import assert from 'node:assert/strict'; + import { verifyQualifiedAcpxInstallation } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/acpx/installation-integrity.js'; + import { resolveQualifiedAcpxProfile } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js'; + const profile = resolveQualifiedAcpxProfile('grok', 'grok-4.7'); + const inspect = () => verifyQualifiedAcpxInstallation(profile, () => { throw new Error('Grok must not resolve an npm package'); }); + if (process.argv[2] === 'missing') await assert.rejects(inspect, /prerequisite missing/); + else { const installation = await inspect(); assert.equal(installation.agentServerPackageJsonPath, null); assert.equal(installation.agentRuntimePackageJsonPath, null); await (await installation.openCommand()).close(); } + `; + writeFileSync(join(assets, 'probe.mjs'), probe, { mode: 0o644 }); + isolated(['node', '/packages/probe.mjs', 'missing']); + // Provision as the unprivileged verification user, never into the host's /opt. + // Only the positive probe sees this file at the canonical sandbox path. + run(process.execPath, [join(repo, 'packages/paperclip-runner/scripts/provision-grok.mjs'), prerequisite]); + isolated(['node', '/packages/probe.mjs', 'present'], { prerequisite }); + console.log(JSON.stringify({ schema: 'paperclip.grok.public-npm-install.v1', sourceRevision, releaseVersion, lifecycleScriptsEnabled: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerUid, consumerLockPreserved: true, cleanNpmInstall: true, packageCount: needed.size, builtinLauncherPresent: true, separateGrokPackage: false, npmProvisionedBinary: false, missingPrerequisiteRejected: true, provisionedBinaryVerified: true, commandLeaseVerified: true, providerCalls: 0 })); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index 0531f713ca..f735c6f13c 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -197,6 +197,21 @@ describe.sequential("cli auth routes", () => { expect(res.body.canApprove).toBe(false); }); + it.each([ + ["cloud_tenant", "board", false, false, true], + ["cloud_tenant", "instance_admin_required", false, false, false], + ["session", "board", false, false, true], + ["board_api_key", "board", false, true, false], + ])("reports CLI approval for %s requesting %s", async (source, requestedAccess, isInstanceAdmin, requiresSignIn, canApprove) => { + mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ + id: "12345678-1234-4123-8123-123456789abc", status: "pending", requestedAccess, + }); + const app = await createApp({ type: "board", source, userId: "user-1", isInstanceAdmin }); + const res = await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret"); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ requiresSignIn, canApprove }); + }); + it.each(["PASTE_ID_HERE", "not-a-uuid"])("rejects malformed challenge ID %s before calling the service", async (id) => { const app = await createApp({ type: "board", userId: "user-1", source: "session" }); const responses = [ diff --git a/server/src/__tests__/docker-build-stamp.test.ts b/server/src/__tests__/docker-build-stamp.test.ts index 285fb34fd2..8d2b726bba 100644 --- a/server/src/__tests__/docker-build-stamp.test.ts +++ b/server/src/__tests__/docker-build-stamp.test.ts @@ -29,7 +29,7 @@ const previewWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows", */ function stageBody(source: string, stageName: string): string { const froms = [...source.matchAll(/^FROM .*$/gm)]; - const startIdx = froms.findIndex((m) => new RegExp(`\\bAS ${stageName}\\b`).test(m[0])); + const startIdx = froms.findIndex((m) => new RegExp(`\\bAS ${stageName}\\s*$`).test(m[0])); expect(startIdx, `Dockerfile must declare a '${stageName}' stage`).toBeGreaterThanOrEqual(0); const start = froms[startIdx].index ?? 0; const end = froms[startIdx + 1]?.index ?? source.length; @@ -104,3 +104,23 @@ describe("Docker Rust dependency cache", () => { expect(stageBody(dockerfile, "build")).toContain("FROM runner-build AS build"); }); }); + + +describe("Cloud remote provider pack", () => { + it("ships a build-owned pack without provisioning Grok on the controller", () => { + const pack = stageBody(dockerfile, "cloud-provider-pack"); + const cloud = stageBody(dockerfile, "cloud"); + expect(pack).toContain('PAPERCLIP_RUNNER_SOURCE_REVISION="${PAPERCLIP_BUILD_COMMIT}"'); + expect(pack).toContain("build-provider-pack.mjs /provider-pack"); + expect(pack).toContain('if [ -n "${PAPERCLIP_BUILD_COMMIT}" ]; then'); + expect(pack).toContain("mkdir -p /provider-pack"); + expect(pack).toContain("Skipping remote provider pack"); + expect(cloud).toContain("--from=cloud-provider-pack /provider-pack /opt/paperclip-runner/provider-pack"); + expect(cloud).not.toContain("--chown=node:node --from=cloud-provider-pack"); + expect(cloud).toContain("chmod -R a+rX /opt/paperclip-runner/provider-pack"); + expect(cloud).toContain("gosu 65534:65534 node"); + expect(cloud).toContain("Object.values(manifest.payload.artifacts)"); + expect(cloud).toContain("PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH=/opt/paperclip-runner/provider-pack"); + expect(dockerfile).not.toContain("provision-grok.mjs"); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 475843cf30..d35c4586d7 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2798,7 +2798,7 @@ export function accessRoutes( const isSignedInBoardUser = req.actor.type === "board" && - (req.actor.source === "session" || isLocalImplicit(req)) && + (req.actor.source === "session" || req.actor.source === "cloud_tenant" || isLocalImplicit(req)) && Boolean(req.actor.userId); const canApprove = isSignedInBoardUser && diff --git a/server/src/services/heartbeat-runner-provider-config.test.ts b/server/src/services/heartbeat-runner-provider-config.test.ts index 90b78278df..638fac7bfb 100644 --- a/server/src/services/heartbeat-runner-provider-config.test.ts +++ b/server/src/services/heartbeat-runner-provider-config.test.ts @@ -7,6 +7,21 @@ import { } from "./native-runtime/provider-profile.js"; describe("Paperclip Runner native provider configuration", () => { + it.each([undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"])( + "passes Grok's full-auto default or explicit %s policy to the native runner", + (acpxPermissionMode) => { + expect(resolvePaperclipRunnerNativeProviderInput({ + backend: "acpx_runtime", + adapterConfig: { provider: "acpx", acpxAgent: "grok", acpxPermissionMode }, + })).toEqual({ + provider: "acpx", + acpxAgent: "grok", + model: "grok-4.7", + acpxPermissionMode: acpxPermissionMode ?? "approve-all", + }); + }, + ); + it("qualifies native Codex only in never-ask mode", () => { expect( resolvePaperclipRunnerNativeProviderInput({ diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index ddaaded398..d533986d90 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1041,9 +1041,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); + const grokLauncher = "grok-binary\n"; + await mkdir(join(root, "dist/providers/grok"), { recursive: true }); + await writeFile(join(root, "dist/providers/grok/launcher.cjs"), grokLauncher); await writeFile( join(root, "dist", "cli", "opencode-app-server-proxy.cjs"), proxy, @@ -1088,7 +1088,7 @@ describe("remote provider pack manifest", () => { "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", }, artifacts: { - grokExecutable: { path: "node_modules/@paperclipai/grok-acp/bin/grok", sha256: digest(grokExecutable) }, + grokLauncher: { path: "dist/providers/grok/launcher.cjs", sha256: digest(grokLauncher) }, nodeCommand: { path: "node_modules/node/bin/node", sha256: digest(node), @@ -10642,6 +10642,69 @@ describe("runnerd provider runtime wiring", () => { ); }); + it("archives failover evidence with an explicitly replaced provider session", async () => { + const remoteCwd = join(isolatedStateDirectory, "remote"); + const remoteExecute = vi.fn(async (command: { command: string; args?: string[] }) => { + if (command.args?.[0] === "--build-metadata") return { + exitCode: 0, timedOut: false, stdout: JSON.stringify({ + schema: "paperclip-runner/runnerd-build-metadata/v1", binaryName: "paperclip-runnerd", + packageName: "@paperclipai/paperclip-runner", binaryContractVersion: 2, + durableSessionCapabilities: ["unlimited_runtime", "connection_lease_renewal"], + prpTransportModes: ["listen_ws"], + }), stderr: "", + }; + if (command.args?.[0] === "--version") return { + exitCode: 0, timedOut: false, stdout: "codex-cli 0.156.0", stderr: "", + }; + if (command.args?.[1]?.includes("base64")) return { + exitCode: 1, timedOut: false, stdout: "", stderr: "", + }; + return { exitCode: 0, timedOut: false, stdout: "", stderr: "" }; + }); + let prepareReplacement!: () => Promise; + const replacement = { close: vi.fn(async () => undefined) }; + const openSession = vi.fn(async () => { + await prepareReplacement(); + return replacement; + }); + state.createBackend.mockReturnValueOnce({ kind: "test", openSession } as never); + const backend = await createRunnerdBackend({ + db: leaseDb(execution), execution, runnerInstanceId: "runner-replacement", + runnerIngressAuthorized: true, + runnerExecutionTarget: { + kind: "remote", transport: "sandbox", remoteCwd, environmentId: "environment", + leaseId: "lease-created", providerKey: "daytona", reusableLeaseConfigured: true, + effectiveCapabilities: { runnerWebSocketIngress: true }, + sandboxLeaseAcquisition: { outcome: "created", providerLeaseId: "sandbox-created" }, + runner: { execute: remoteExecute, syncIn: vi.fn(async () => undefined) }, + } as never, + }); + state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); + const options = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & { + prepareExternalRunnerState: () => Promise; + }; + prepareReplacement = options.prepareExternalRunnerState; + const root = options.stateDirectory!; + for (const name of ["current", "previous"]) { + await mkdir(join(root, "failover-backups", name), { recursive: true }); + await writeFile(join(root, "failover-backups", name, "manifest.json"), JSON.stringify({ priorSession: name })); + } + // Ambiguous ordinary recovery must still fail closed. Only the runtime's + // explicitly admitted replacement may retire these prior-session backups. + await expect(prepareReplacement()).rejects.toThrow("runner_harness_state_mismatch"); + await expect(backend.openReplacementSession!({ + identity: { runId: execution.binding.runId }, workingDirectory: execution.workspace.cwd, + } as never, {} as never)).resolves.toBe(replacement); + expect(openSession).toHaveBeenCalledOnce(); + await expect(access(join(root, "failover-backups"))).rejects.toThrow(); + const archives = await readdir(join(root, "continuity-breaks")); + expect(archives).toHaveLength(1); + for (const name of ["current", "previous"]) { + expect(JSON.parse(await readFile(join(root, "continuity-breaks", archives[0]!, "failover-backups", name, "manifest.json"), "utf8"))) + .toEqual({ priorSession: name }); + } + }); + it.each(["fresh", "existing_state", "symlink_parent", "wrong_identity", "connected", "pending_turn", "remote_probe_failed", "backup_present"])( "bootstraps only an untouched provider session in a resumed workspace lease: %s", async (scenario) => { const remoteCwd = join(isolatedStateDirectory, "remote"); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 787412db4d..7b682232f8 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -9275,7 +9275,7 @@ const REMOTE_PROVIDER_PACK_PROFILE_DIGESTS = { "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", } as const; const REMOTE_PROVIDER_PACK_ARTIFACT_PATHS = { - grokExecutable: "node_modules/@paperclipai/grok-acp/bin/grok", + grokLauncher: "dist/providers/grok/launcher.cjs", nodeCommand: "node_modules/node/bin/node", productionLock: "pnpm-lock.yaml", opencodeCommand: "node_modules/.bin/opencode", @@ -9295,7 +9295,7 @@ type RemoteProviderPackManifest = { bridgeDigest: string; acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS; artifacts: { - grokExecutable: { path: string; sha256: string }; + grokLauncher: { path: string; sha256: string }; nodeCommand: { path: string; sha256: string }; productionLock: { path: string; sha256: string }; opencodeCommand: { path: string; sha256: string }; @@ -9394,7 +9394,7 @@ export function readRemoteProviderPackManifest( ); } const artifactEntries = [ - ["Grok executable", payload.artifacts?.grokExecutable, REMOTE_PROVIDER_PACK_ARTIFACT_PATHS.grokExecutable], + ["Grok builtin launcher", payload.artifacts?.grokLauncher, REMOTE_PROVIDER_PACK_ARTIFACT_PATHS.grokLauncher], [ "provider Node", payload.artifacts?.nodeCommand, @@ -10854,14 +10854,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','grokExecutable']){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','grokLauncher']){const artifact=manifest.payload.artifacts[name];if(hash(artifact.path)!==artifact.sha256)throw new Error(name+' digest mismatch')}", "if(tree(path.join(root,'dist'))!==manifest.payload.distDigest)throw new Error('dist tree digest mismatch')", "const version=process.versions.node.split('.').map(Number)", "const minimum=manifest.payload.pins.nodeMinimum.split('.').map(Number)", "if(version[0]JSON.parse(fs.readFileSync(path.join(root,'node_modules',...pkg.split('/'),'package.json'),'utf8')).version", - "const expectedPackages={acpx:manifest.payload.pins.acpx,'@agentclientprotocol/claude-agent-acp':manifest.payload.pins.claudeAcp,'@agentclientprotocol/codex-acp':manifest.payload.pins.codexAcp,'opencode-ai':manifest.payload.pins.opencode,'@paperclipai/grok-acp':manifest.payload.pins.grok}", + "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}", "for(const [pkg,version] of Object.entries(expectedPackages))if(packageVersion(pkg)!==version)throw new Error(pkg+' version mismatch')", ].join(";"); const verified = await remoteCommandRunner.execute({ @@ -12203,6 +12203,10 @@ async function createRunnerdBackendWithinSessionClaim( "codex-home", "opencode", "acpx", + // Backups belong to the retired provider session. Leaving them active + // makes the fresh replacement look like ambiguous lost harness state. + // Keep their evidence inside the same continuity-break archive. + "failover-backups", ]) { const source = resolve(root, name); if (existsSync(source)) renameSync(source, resolve(archiveRoot, name)); diff --git a/tests/runner-e2e/daytona-image-content.ts b/tests/runner-e2e/daytona-image-content.ts index 7f8b3b0386..bcf682284d 100644 --- a/tests/runner-e2e/daytona-image-content.ts +++ b/tests/runner-e2e/daytona-image-content.ts @@ -27,10 +27,7 @@ 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/scripts/provision-grok.mjs", "packages/paperclip-runner/package.json", "packages/paperclip-runner/protocol", "packages/paperclip-runner/runner/Cargo.lock", diff --git a/tests/runner-e2e/daytona-image.test.ts b/tests/runner-e2e/daytona-image.test.ts index 74e79ca555..856190f842 100644 --- a/tests/runner-e2e/daytona-image.test.ts +++ b/tests/runner-e2e/daytona-image.test.ts @@ -14,16 +14,20 @@ const repositoryRoot = path.resolve(import.meta.dirname, "../.."); describe("runner E2E Daytona image contract", () => { it("keeps the qualified native Grok binary separate from the legacy command", async () => { - const [dockerfile, packBuilder, nativePackage] = await Promise.all([ + const [dockerfile, packBuilder, runnerPackage] = await Promise.all([ readFile(path.join(repositoryRoot, "docker/daytona-runner/Dockerfile"), "utf8"), readFile(path.join(repositoryRoot, "packages/paperclip-runner/scripts/build-provider-pack.mjs"), "utf8"), - readFile(path.join(repositoryRoot, "packages/grok-acp/package.json"), "utf8"), + readFile(path.join(repositoryRoot, "packages/paperclip-runner/package.json"), "utf8"), ]); expect(dockerfile).toMatch(/@xai-official\/grok@\d+\.\d+\.\d+/); expect(dockerfile).not.toMatch(/for cli in[^;]*\bgrok\b/); expect(packBuilder).not.toMatch(/writePortable\w+Shim\("grok"/); - expect(JSON.parse(nativePackage).bin).not.toHaveProperty("grok"); - expect(packBuilder).toContain('path: "node_modules/@paperclipai/grok-acp/bin/grok"'); + expect(JSON.parse(runnerPackage).dependencies).not.toHaveProperty("@paperclipai/grok-acp"); + expect(packBuilder).toContain('path: "dist/providers/grok/launcher.cjs"'); + expect(dockerfile).toContain("scripts/provision-grok.mjs"); + expect(dockerfile).toContain("/opt/paperclip/providers/grok/1.0.13/grok"); + expect(DAYTONA_IMAGE_INPUT_PATHS).toContain("packages/paperclip-runner/scripts/provision-grok.mjs"); + expect(DAYTONA_IMAGE_INPUT_PATHS).not.toContain("packages/grok-acp/package.json"); }); it("builds runnerd and the provider pack and verifies every required transport", async () => { diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index 31e6337af3..c1173c16be 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -321,7 +321,7 @@ describe("public repository paid workflow security", () => { expect(grokPreparation).toBeGreaterThan(paidInstall); expect(paidExecution).toBeGreaterThan(grokPreparation); expect(paidJob).toContain("if: matrix.environmentId == 'local' && (matrix.profileId == 'runner-acpx-grok' || matrix.profileId == 'runner-acpx-grok-subscription')"); - expect(paidJob).toContain("run: node packages/grok-acp/install.mjs"); + expect(paidJob).toContain("run: sudo node packages/paperclip-runner/scripts/provision-grok.mjs /opt/paperclip/providers/grok/1.0.13/grok"); const everydayOracleStep = paidJob.slice( everydayOraclePreparation, diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 47ca43073d..a665d2b221 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -25,6 +25,18 @@ function renderRunner(config: Record): string { } describe("Paperclip Runner Codex configuration", () => { + it.each([ + [undefined, "Full auto (approve all)"], + ["approve-paperclip", "Automatic Paperclip actions"], + ["approve-reads", "Allow Paperclip reads"], + ["deny-all", "Deny all"], + ])("displays Grok's default or saved permission mode %s", (acpxPermissionMode, label) => { + const html = renderRunner({ provider: "acpx", acpxAgent: "grok", acpxPermissionMode }); + expect(html).toContain(''); + expect(html).toContain('aria-label="Permission mode"'); + expect(html).toContain(label); + }); + it("exposes all qualified provider choices", () => { const html = renderRunner({ provider: "codex" }); diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index e31aee17ba..a95b011a1b 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -1723,6 +1723,42 @@ describe("AgentConfigForm environment selector", () => { expect(mockAgentsApi.cancelAdapterAuthLogin).not.toHaveBeenCalled(); }); + it("recovers a previous-environment sign-in only after explicit successful cancellation", async () => { + const intent = { provider: "xai", method: "subscription", name: "My Grok subscription", ownership: "personal", agentIds: [], allAgents: true } as const; + mockAgentsApi.getActiveAdapterAuthLoginSession.mockResolvedValueOnce({ + sessionId: "previous-login", environmentId: "previous-sandbox", aiConnection: intent, + status: "waiting_for_user", prompt: null, + }).mockImplementation(noActiveSession); + mockAgentsApi.cancelAdapterAuthLogin.mockRejectedValueOnce(new Error("Network unavailable")); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); roots.push(root); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + await act(async () => { + root.render( + + ); + }); + await flushUntil(() => Boolean(findButton(container, "Cancel previous sign-in and retry"))); + expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled(); + expect(mockAgentsApi.cancelAdapterAuthLogin).not.toHaveBeenCalled(); + await act(async () => findButton(container, "Cancel previous sign-in and retry")!.click()); + await flushUntil(() => container.textContent?.includes("Could not cancel") ?? false); + expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled(); + let release!: () => void; + mockAgentsApi.cancelAdapterAuthLogin.mockImplementationOnce(() => new Promise((resolve) => { release = resolve; })); + await act(async () => findButton(container, "Cancel previous sign-in and retry")!.click()); + await flushReact(); + expect(findButton(container, "Cancel previous sign-in and retry")!.disabled).toBe(true); + expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled(); + await act(async () => release()); + await flushUntil(() => mockAgentsApi.startAdapterAuthLogin.mock.calls.length === 1); + expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenLastCalledWith("company-1", "grok_local", "previous-login"); + expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith("company-1", "grok_local", { environmentId: "new-sandbox", aiConnection: intent }); + queryClient.clear(); + }); + it("offers no Cancel in the onboarding chrome", async () => { // The card carried a Cancel beside its instruction, directly above the // step's own Back. Two ways out of one screen is one too many, so the diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index ad44b7b396..1c5fa70d8f 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -2341,6 +2341,12 @@ export function AdapterLoginPanel(props: AdapterLoginPanelProps) { return ; } +class AdapterLoginConflictError extends Error { + constructor(readonly sessionId: string) { + super("Another sign-in attempt is active. Finish or cancel that attempt before starting a new sign-in."); + } +} + function DisplayedCodeLoginPanel({ companyId, adapterType, @@ -2415,7 +2421,7 @@ function DisplayedCodeLoginPanel({ try { const active = await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType); if (!active) return null; - if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new Error("Another sign-in attempt is active. Finish or cancel it in its original account setup before starting this one."); + if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new AdapterLoginConflictError(active.sessionId); return active; } catch (error) { if (error instanceof ApiError && error.status === 404) return null; @@ -2521,6 +2527,20 @@ function DisplayedCodeLoginPanel({ // before the session id lands and start a second login the server would // count against the per-owner cap. const autoStartedRef = useRef(false); + const cancelConflictingLogin = useMutation({ + mutationFn: async () => { + const conflict = activeSessionQuery.error; + if (!(conflict instanceof AdapterLoginConflictError)) return; + await agentsApi.cancelAdapterAuthLogin(companyId, adapterType, conflict.sessionId); + }, + onSuccess: async () => { + autoStartedRef.current = false; + resumeAttemptedRef.current = false; + setStartError(null); + await activeSessionQuery.refetch(); + }, + onError: () => setStartError("Could not cancel the previous sign-in. Retry before starting a new one."), + }); const startLoginRef = useRef(startLogin.mutate); startLoginRef.current = startLogin.mutate; useEffect(() => { @@ -2617,9 +2637,15 @@ function DisplayedCodeLoginPanel({ mode="displayed_code" > {startError ? ( -

- {startError} -

+
+

{startError}

+ {activeSessionQuery.error instanceof AdapterLoginConflictError && ( + + )} +
) : failed ? (

{status === "timed_out" diff --git a/ui/src/components/NewAgentDialog.test.tsx b/ui/src/components/NewAgentDialog.test.tsx index 3259e030f0..da2afdf88c 100644 --- a/ui/src/components/NewAgentDialog.test.tsx +++ b/ui/src/components/NewAgentDialog.test.tsx @@ -132,8 +132,9 @@ it.each([false, undefined])( }, ); -it("offers Claude, Codex, OpenCode, and Grok on Cloud, even with the runner enabled", async () => { +it.each([true, false, undefined])("gates the Cloud native runner on explicit enablement (%s)", async (enableNativeRunner) => { await act(async () => { + cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner }); cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true }, @@ -160,8 +161,7 @@ it("offers Claude, Codex, OpenCode, and Grok on Cloud, even with the runner enab [...document.querySelectorAll('input[type="radio"]')].map( (input) => input.value, ), - ).toEqual(["claude_local", "codex_local", "opencode_local", "grok_local"]); - expect(document.body.textContent).not.toContain("CLI harness"); + ).toEqual(["claude_local", "codex_local", "opencode_local", "grok_local", ...(enableNativeRunner ? ["paperclip_runner"] : [])]); await act(async () => document.querySelector('input[value="grok_local"]')!.click(), ); diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index 7f55a0b236..dcb167010b 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -719,6 +719,26 @@ function Setup({ center /> +

+ + + +
{ setEnvironmentOverride(event.target.value); setConnection(null); @@ -1120,7 +1140,7 @@ function Setup({ Default: {environmentLabel} {(envs.data ?? []) - .filter((env) => env.status === "active") + .filter((env) => env.status === "active" && (!managedOnly || env.driver !== "local")) .map((env) => (